diff --git a/src/scripts/src/choices.js b/src/scripts/src/choices.js index c538dde..4f8e05b 100644 --- a/src/scripts/src/choices.js +++ b/src/scripts/src/choices.js @@ -6,7 +6,7 @@ import Input from './components/input'; import List from './components/list'; import WrappedInput from './components/wrapped-input'; import WrappedSelect from './components/wrapped-select'; -import { DEFAULT_CONFIG, DEFAULT_CLASSNAMES, EVENTS, KEY_CODES } from './constants'; +import { DEFAULT_CONFIG, DEFAULT_CLASSNAMES, EVENTS, KEY_CODES, SCROLLING_SPEED } from './constants'; import { TEMPLATES } from './templates'; import { addChoice, filterChoices, activateChoices, clearChoices } from './actions/choices'; import { addItem, removeItem, highlightItem } from './actions/items'; @@ -24,7 +24,6 @@ import { sortByAlpha, sortByScore, generateId, - triggerEvent, findAncestorByAttrName, regexFilter, } from './lib/utils'; @@ -121,9 +120,9 @@ class Choices { this.presetItems = this.config.items; // Then add any values passed from attribute - if (this.passedElement.element.value) { + if (this.passedElement.getValue()) { this.presetItems = this.presetItems.concat( - this.passedElement.element.value.split(this.config.delimiter), + this.passedElement.getValue().split(this.config.delimiter), ); } @@ -219,15 +218,7 @@ class Choices { // Remove all event listeners this._removeEventListeners(); this.passedElement.reveal(); - - // Move passed element back to original position - this.containerOuter.element.parentNode.insertBefore( - this.passedElement.element, - this.containerOuter.element, - ); - - // Remove added elements - this.containerOuter.element.parentNode.removeChild(this.containerOuter.element); + this.containerOuter.revert(this.passedElement.element); // Clear data store this.clearStore(); @@ -292,7 +283,7 @@ class Choices { (this.isSelectOneElement || !choice.selected) : true; if (shouldRender) { - const dropdownItem = this._getTemplate('choice', choice); + const dropdownItem = this._getTemplate('choice', choice, this.config.itemSelectText); choicesFragment.appendChild(dropdownItem); } }; @@ -359,9 +350,9 @@ class Choices { // Simplify store data to just values const itemsFiltered = this.store.getItemsReducedToValues(items); const itemsFilteredString = itemsFiltered.join(this.config.delimiter); + // Update the value of the hidden input - this.passedElement.element.setAttribute('value', itemsFilteredString); - this.passedElement.element.value = itemsFilteredString; + this.passedElement.setValue(itemsFilteredString); } else { const selectedOptionsFragment = document.createDocumentFragment(); const addOptionToFragment = (item) => { @@ -373,15 +364,13 @@ class Choices { // Add each list item to list items.forEach(item => addOptionToFragment(item)); - - // Update selected choices - this.passedElement.element.innerHTML = ''; - this.passedElement.element.appendChild(selectedOptionsFragment); + // Update the options of the hidden input + this.passedElement.setOptions(selectedOptionsFragment); } const addItemToFragment = (item) => { // Create new list element - const listItem = this._getTemplate('item', item); + const listItem = this._getTemplate('item', item, this.config.removeItemButton); // Append it to list itemListFragment.appendChild(listItem); }; @@ -399,104 +388,104 @@ class Choices { */ render() { this.currentState = this.store.getState(); + const stateChanged = ( + this.currentState.choices !== this.prevState.choices || + this.currentState.groups !== this.prevState.groups || + this.currentState.items !== this.prevState.items + ); - // 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 || - this.currentState.items !== this.prevState.items) && - this.isSelectElement - ) { - // Get active groups/choices - const activeGroups = this.store.getGroupsFilteredByActive(); - const activeChoices = this.store.getChoicesFilteredByActive(); - - let choiceListFragment = document.createDocumentFragment(); - - // Clear choices - this.choiceList.clear(); - - // Scroll back to top of choices list - if (this.config.resetScrollPosition) { - this.choiceList.scrollTo(0); - } - - // If we have grouped options - if (activeGroups.length >= 1 && this.isSearching !== true) { - // If we have a placeholder choice along with groups - const activePlaceholders = activeChoices.filter( - activeChoice => activeChoice.placeholder === true && activeChoice.groupId === -1, - ); - if (activePlaceholders.length >= 1) { - choiceListFragment = this.renderChoices(activePlaceholders, choiceListFragment); - } - choiceListFragment = this.renderGroups(activeGroups, activeChoices, choiceListFragment); - } else if (activeChoices.length >= 1) { - choiceListFragment = this.renderChoices(activeChoices, choiceListFragment); - } - - const activeItems = this.store.getItemsFilteredByActive(); - const canAddItem = this._canAddItem(activeItems, this.input.getValue()); - - // If we have choices to show - if (choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0) { - // ...and we can select them - if (canAddItem.response) { - // ...append them and highlight the first choice - this.choiceList.append(choiceListFragment); - this._highlightChoice(); - } else { - // ...otherwise show a notice - this.choiceList.append(this._getTemplate('notice', canAddItem.notice)); - } - } else { - // Otherwise show a notice - let dropdownItem; - let notice; - - if (this.isSearching) { - notice = isType('Function', this.config.noResultsText) ? - this.config.noResultsText() : - this.config.noResultsText; - - dropdownItem = this._getTemplate('notice', notice, 'no-results'); - } else { - notice = isType('Function', this.config.noChoicesText) ? - this.config.noChoicesText() : - this.config.noChoicesText; - - dropdownItem = this._getTemplate('notice', notice, 'no-choices'); - } - - this.choiceList.append(dropdownItem); - } - } - - // Items - if (this.currentState.items !== this.prevState.items) { - // Get active items (items that can be selected) - const activeItems = this.store.getItemsFilteredByActive(); - - // Clear list - this.itemList.clear(); - - if (activeItems && activeItems.length) { - // Create a fragment to store our list items - // (so we don't have to update the DOM for each item) - const itemListFragment = this.renderItems(activeItems); - - // If we have items to add - if (itemListFragment.childNodes) { - // Update list - this.itemList.append(itemListFragment); - } - } - } - - this.prevState = this.currentState; + if (!stateChanged) { + return; } + + /* Choices */ + + if (this.isSelectElement) { + // Get active groups/choices + const activeGroups = this.store.getGroupsFilteredByActive(); + const activeChoices = this.store.getChoicesFilteredByActive(); + + let choiceListFragment = document.createDocumentFragment(); + + // Clear choices + this.choiceList.clear(); + + // Scroll back to top of choices list + if (this.config.resetScrollPosition) { + this.choiceList.scrollTo(0); + } + + // If we have grouped options + if (activeGroups.length >= 1 && !this.isSearching) { + // If we have a placeholder choice along with groups + const activePlaceholders = activeChoices.filter( + activeChoice => activeChoice.placeholder === true && activeChoice.groupId === -1, + ); + if (activePlaceholders.length >= 1) { + choiceListFragment = this.renderChoices(activePlaceholders, choiceListFragment); + } + choiceListFragment = this.renderGroups(activeGroups, activeChoices, choiceListFragment); + } else if (activeChoices.length >= 1) { + choiceListFragment = this.renderChoices(activeChoices, choiceListFragment); + } + + const activeItems = this.store.getItemsFilteredByActive(); + const canAddItem = this._canAddItem(activeItems, this.input.getValue()); + + // If we have choices to show + if (choiceListFragment.childNodes && choiceListFragment.childNodes.length > 0) { + // ...and we can select them + if (canAddItem.response) { + // ...append them and highlight the first choice + this.choiceList.append(choiceListFragment); + this._highlightChoice(); + } else { + // ...otherwise show a notice + this.choiceList.append(this._getTemplate('notice', canAddItem.notice)); + } + } else { + // Otherwise show a notice + let dropdownItem; + let notice; + + if (this.isSearching) { + notice = isType('Function', this.config.noResultsText) ? + this.config.noResultsText() : + this.config.noResultsText; + + dropdownItem = this._getTemplate('notice', notice, 'no-results'); + } else { + notice = isType('Function', this.config.noChoicesText) ? + this.config.noChoicesText() : + this.config.noChoicesText; + + dropdownItem = this._getTemplate('notice', notice, 'no-choices'); + } + + this.choiceList.append(dropdownItem); + } + } + + /* Items */ + + // Get active items (items that can be selected) + const activeItems = this.store.getItemsFilteredByActive() || []; + // Clear list + this.itemList.clear(); + + if (activeItems.length) { + // Create a fragment to store our list items + // (so we don't have to update the DOM for each item) + const itemListFragment = this.renderItems(activeItems); + + // If we have items to add + if (itemListFragment.childNodes) { + // Update list + this.itemList.append(itemListFragment); + } + } + + this.prevState = this.currentState; } /** @@ -530,7 +519,7 @@ class Choices { eventResponse.groupValue = group.value; } - triggerEvent(this.passedElement.element, EVENTS.highlightItem, eventResponse); + this.passedElement.triggerEvent(EVENTS.highlightItem, eventResponse); } return this; @@ -560,11 +549,8 @@ class Choices { eventResponse.groupValue = group.value; } - this.store.dispatch( - highlightItem(id, false), - ); - - triggerEvent(this.passedElement.element, EVENTS.highlightItem, eventResponse); + this.store.dispatch(highlightItem(id, false)); + this.passedElement.triggerEvent(EVENTS.highlightItem, eventResponse); return this; } @@ -668,8 +654,8 @@ class Choices { this.containerOuter.open(this.dropdown.getVerticalPos()); this.dropdown.show(); this.input.activate(focusInput); + this.passedElement.triggerEvent(EVENTS.showDropdown, {}); - triggerEvent(this.passedElement.element, EVENTS.showDropdown, {}); return this; } @@ -686,8 +672,7 @@ class Choices { this.containerOuter.close(); this.dropdown.hide(); this.input.deactivate(blurInput); - - triggerEvent(this.passedElement.element, EVENTS.hideDropdown, {}); + this.passedElement.triggerEvent(EVENTS.hideDropdown, {}); return this; } @@ -922,11 +907,10 @@ class Choices { return this; } - this.passedElement.element.disabled = false; + this.passedElement.enable(); if (this.containerOuter.isDisabled) { this._addEventListeners(); - this.passedElement.element.removeAttribute('disabled'); this.input.enable(); this.containerOuter.enable(); } @@ -944,11 +928,10 @@ class Choices { return this; } - this.passedElement.element.disabled = true; + this.passedElement.disable(); if (!this.containerOuter.isDisabled) { this._removeEventListeners(); - this.passedElement.element.setAttribute('disabled', ''); this.input.disable(); this.containerOuter.disable(); } @@ -992,7 +975,7 @@ class Choices { return; } - triggerEvent(this.passedElement.element, EVENTS.change, { + this.passedElement.triggerEvent(EVENTS.change, { value, }); } @@ -1102,7 +1085,7 @@ class Choices { // Update choice keyCode choice.keyCode = passedKeyCode; - triggerEvent(this.passedElement.element, EVENTS.choice, { + this.passedElement.triggerEvent(EVENTS.choice, { choice, }); @@ -1155,6 +1138,7 @@ class Choices { this._triggerChange(lastItem.value); } else { if (!hasHighlightedItems) { + // Highlight last item if none already highlighted this.highlightItem(lastItem, false); } this.removeHighlightedItems(true); @@ -1312,26 +1296,26 @@ class Choices { 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.getSearchableChoices(); - const needle = newValue; - const keys = isType('Array', this.config.searchFields) ? - this.config.searchFields : - [this.config.searchFields]; - const options = Object.assign(this.config.fuseOptions, { keys }); - const fuse = new Fuse(haystack, options); - const results = fuse.search(needle); - - this.currentValue = newValue; - this.highlightPosition = 0; - this.isSearching = true; - this.store.dispatch(filterChoices(results)); - - return results.length; + if (newValue.length < 1 && newValue === `${currentValue} `) { + return 0; } - return 0; + // If new value matches the desired length and is not the same as the current value with a space + const haystack = this.store.getSearchableChoices(); + const needle = newValue; + const keys = isType('Array', this.config.searchFields) ? + this.config.searchFields : + [this.config.searchFields]; + const options = Object.assign(this.config.fuseOptions, { keys }); + const fuse = new Fuse(haystack, options); + const results = fuse.search(needle); + + this.currentValue = newValue; + this.highlightPosition = 0; + this.isSearching = true; + this.store.dispatch(filterChoices(results)); + + return results.length; } /** @@ -1352,7 +1336,7 @@ class Choices { if (value && value.length >= this.config.searchFloor) { const resultCount = this.config.searchChoices ? this._searchChoices(value) : 0; // Trigger search event - triggerEvent(this.passedElement.element, EVENTS.search, { + this.passedElement.triggerEvent(EVENTS.search, { value, resultCount, }); @@ -1453,7 +1437,7 @@ class Choices { this.canSearch = false; if ( this.config.removeItems && - !this.input.element.value && + !this.input.getValue() && this.input.element === document.activeElement ) { // Highlight items @@ -1465,7 +1449,7 @@ class Choices { const onEnterKey = () => { // If enter key is pressed and the input has a value if (this.isTextElement && target.value) { - const value = this.input.element.value; + const value = this.input.getValue(); const canAddItem = this._canAddItem(activeItems, value); // All is good, add @@ -1592,7 +1576,7 @@ class Choices { return; } - const value = this.input.element.value; + const value = this.input.getValue(); const activeItems = this.store.getItemsFilteredByActive(); const canAddItem = this._canAddItem(activeItems, value); @@ -1614,20 +1598,18 @@ class Choices { this.hideDropdown(); } } else { - const backKey = 46; - const deleteKey = 8; + const backKey = KEY_CODES.BACK_KEY; + const deleteKey = KEY_CODES.DELETE_KEY; // 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.isTextElement && this.isSearching) { this.isSearching = false; - this.store.dispatch( - activateChoices(true), - ); + this.store.dispatch(activateChoices(true)); } } else if (this.canSearch && canAddItem.response) { - this._handleSearch(this.input.element.value); + this._handleSearch(this.input.getValue()); } } // Re-establish canSearch value from changes in _onKeyDown @@ -1784,33 +1766,34 @@ class Choices { */ _onFocus(e) { const target = e.target; - // If target is something that concerns us - if (this.containerOuter.element.contains(target)) { - const focusActions = { - text: () => { - if (target === this.input.element) { - this.containerOuter.addFocusState(); - } - }, - 'select-one': () => { - this.containerOuter.addFocusState(); - if (target === this.input.element) { - // Show dropdown if it isn't already showing - this.showDropdown(); - } - }, - 'select-multiple': () => { - if (target === this.input.element) { - // If element is a select box, the focused element is the container and the dropdown - // isn't already open, focus and show dropdown - this.containerOuter.addFocusState(); - this.showDropdown(true); - } - }, - }; - - focusActions[this.passedElement.element.type](); + if (!this.containerOuter.element.contains(target)) { + return; } + + const focusActions = { + text: () => { + if (target === this.input.element) { + this.containerOuter.addFocusState(); + } + }, + 'select-one': () => { + this.containerOuter.addFocusState(); + if (target === this.input.element) { + // Show dropdown if it isn't already showing + this.showDropdown(); + } + }, + 'select-multiple': () => { + if (target === this.input.element) { + // If element is a select box, the focused element is the container and the dropdown + // isn't already open, focus and show dropdown + this.containerOuter.addFocusState(); + this.showDropdown(true); + } + }, + }; + + focusActions[this.passedElement.element.type](); } /** @@ -1897,7 +1880,7 @@ class Choices { choice.offsetTop; const animateScroll = () => { - const strength = 4; + const strength = SCROLLING_SPEED; const choiceListScrollTop = this.choiceList.scrollPos; let continueAnimation = false; let easing; @@ -2046,7 +2029,7 @@ class Choices { // Trigger change event if (group && group.value) { - triggerEvent(this.passedElement.element, EVENTS.addItem, { + this.passedElement.triggerEvent(EVENTS.addItem, { id, value: passedValue, label: passedLabel, @@ -2054,7 +2037,7 @@ class Choices { keyCode: passedKeyCode, }); } else { - triggerEvent(this.passedElement.element, EVENTS.addItem, { + this.passedElement.triggerEvent(EVENTS.addItem, { id, value: passedValue, label: passedLabel, @@ -2083,19 +2066,17 @@ class Choices { const groupId = item.groupId; const group = groupId >= 0 ? this.store.getGroupById(groupId) : null; - this.store.dispatch( - removeItem(id, choiceId), - ); + this.store.dispatch(removeItem(id, choiceId)); if (group && group.value) { - triggerEvent(this.passedElement.element, EVENTS.removeItem, { + this.passedElement.triggerEvent(EVENTS.removeItem, { id, value, label, groupValue: group.value, }); } else { - triggerEvent(this.passedElement.element, EVENTS.removeItem, { + this.passedElement.triggerEvent(EVENTS.removeItem, { id, value, label, @@ -2263,10 +2244,16 @@ class Choices { */ _createInput() { const direction = this.passedElement.element.getAttribute('dir') || 'ltr'; - const containerOuter = this._getTemplate('containerOuter', direction); + const containerOuter = this._getTemplate('containerOuter', + direction, + this.isSelectElement, + this.isSelectOneElement, + this.config.searchEnabled, + this.passedElement.element.type, + ); const containerInner = this._getTemplate('containerInner'); - const itemList = this._getTemplate('itemList'); - const choiceList = this._getTemplate('choiceList'); + const itemList = this._getTemplate('itemList', this.isSelectOneElement); + const choiceList = this._getTemplate('choiceList', this.isSelectOneElement); const input = this._getTemplate('input'); const dropdown = this._getTemplate('dropdown'); @@ -2281,7 +2268,6 @@ class Choices { // Wrap input in container preserving DOM ordering wrap(this.passedElement.element, this.containerInner.element); - // Wrapper inner container with outer container wrap(this.containerInner.element, this.containerOuter.element); @@ -2304,21 +2290,21 @@ class Choices { dropdown.appendChild(choiceList); } - if (this.isSelectMultipleElement || this.isTextElement) { + if (!this.isSelectOneElement) { this.containerInner.element.appendChild(this.input.element); } else if (this.canSearch) { dropdown.insertBefore(input, dropdown.firstChild); } if (this.isSelectElement) { - const passedGroups = Array.from(this.passedElement.element.getElementsByTagName('OPTGROUP')); + const passedGroups = this.passedElement.getOptionGroups(); this.highlightPosition = 0; this.isSearching = false; if (passedGroups && passedGroups.length) { // If we have a placeholder option - const placeholderChoice = this.passedElement.element.querySelector('option[placeholder]'); + const placeholderChoice = this.passedElement.getPlaceholderOption(); if (placeholderChoice && placeholderChoice.parentNode.tagName === 'SELECT') { this._addChoice( placeholderChoice.value, @@ -2335,7 +2321,7 @@ class Choices { this._addGroup(group, (group.id || null)); }); } else { - const passedOptions = Array.from(this.passedElement.element.options); + const passedOptions = this.passedElement.getOptions(); const filter = this.config.sortFilter; const allChoices = this.presetChoices; diff --git a/src/scripts/src/choices.test.backup.js b/src/scripts/src/choices.test.backup.js index fdcd03d..27dca78 100644 --- a/src/scripts/src/choices.test.backup.js +++ b/src/scripts/src/choices.test.backup.js @@ -32,27 +32,27 @@ describe('Choices', () => { instance.destroy(); }); - it('should be defined', () => { + it('is defined', () => { expect(instance).to.not.be.undefined; }); - it('should have initialised', () => { + it('initialises', () => { expect(instance.initialised).to.be.true; }); - it('should not re-initialise if passed element again', () => { + it('does not re-initialise if passed element again', () => { const reinitialise = new Choices(instance.passedElement.element); sinon.spy(reinitialise, '_createTemplates'); expect(reinitialise._createTemplates.callCount).to.equal(0); }); - it('should have a blank state', () => { + it('has a blank state', () => { expect(instance.currentState.items.length).to.equal(0); expect(instance.currentState.groups.length).to.equal(0); expect(instance.currentState.choices.length).to.equal(0); }); - it('should have config options', () => { + it('has expected config options', () => { expect(instance.config.silent).to.be.a('boolean'); expect(instance.config.items).to.be.an('array'); expect(instance.config.choices).to.be.an('array'); @@ -90,7 +90,7 @@ describe('Choices', () => { expect(instance.config.callbackOnCreateTemplates).to.be.null; }); - it('should expose public methods', () => { + it('exposes public methods', () => { expect(instance.init).to.be.a('function'); expect(instance.destroy).to.be.a('function'); expect(instance.render).to.be.a('function'); @@ -118,35 +118,35 @@ describe('Choices', () => { expect(instance.clearInput).to.be.a('function'); }); - it('should hide passed input', () => { + it('hides passed input', () => { expect(instance.passedElement.element.style.display).to.equal('none'); }); - it('should create an outer container', () => { + it('creates an outer container', () => { expect(instance.containerOuter).to.be.an.instanceof(Container); }); - it('should create an inner container', () => { + it('creates an inner container', () => { expect(instance.containerInner).to.be.an.instanceof(Container); }); - it('should create a choice list', () => { + it('creates a choice list', () => { expect(instance.choiceList).to.be.an.instanceof(List); }); - it('should create an item list', () => { + it('creates an item list', () => { expect(instance.itemList).to.be.an.instanceof(List); }); - it('should create an input', () => { + it('creates an input', () => { expect(instance.input).to.be.an.instanceof(Input); }); - it('should create a dropdown', () => { + it('creates a dropdown', () => { expect(instance.dropdown).to.be.an.instanceof(Dropdown); }); - it('should backup and recover original styles', () => { + it('backs up and recovers original styles', () => { const origStyle = 'background-color: #ccc; margin: 5px padding: 10px;'; instance.destroy(); @@ -165,7 +165,7 @@ describe('Choices', () => { }); }); - describe('should accept text inputs', () => { + describe('text inputs', () => { let input; let instance; @@ -182,12 +182,12 @@ describe('Choices', () => { instance.destroy(); }); - it('should wrap passed input', () => { + it('wraps passed input', () => { instance = new Choices(input); expect(instance.passedElement).to.be.an.instanceof(WrappedInput); }); - it('should accept a user inputted value', () => { + it('accepts a user inputted value', () => { instance = new Choices(input); instance.input.element.focus(); @@ -202,13 +202,13 @@ describe('Choices', () => { expect(instance.currentState.items[0].value).to.include(instance.input.element.value); }); - it('should copy the passed placeholder to the cloned input', () => { + it('copys the passed placeholder to the cloned input', () => { instance = new Choices(input); expect(instance.input.element.placeholder).to.equal(input.placeholder); }); - it('should not allow duplicates if duplicateItems is false', () => { + it('doesn\'t allow duplicate items if duplicateItems is false', () => { instance = new Choices(input, { duplicateItems: false, items: ['test 1'], @@ -226,7 +226,7 @@ describe('Choices', () => { expect(instance.currentState.items[instance.currentState.items.length - 1].value).to.equal(instance.input.element.value); }); - it('should filter input if regexFilter is passed', () => { + it('filters input if regexFilter is passed', () => { instance = new Choices(input, { regexFilter: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, }); @@ -255,7 +255,7 @@ describe('Choices', () => { expect(lastItem.value).not.to.equal('not an email address'); }); - it('should prepend and append values if passed', () => { + it('prepends and appends values if passed', () => { instance = new Choices(input, { prependValue: 'item-', appendValue: '-value', @@ -277,7 +277,7 @@ describe('Choices', () => { }); }); - describe('should accept single select inputs', () => { + describe('single select inputs', () => { let input; let instance; @@ -302,23 +302,23 @@ describe('Choices', () => { instance.destroy(); }); - it('should wrap passed input', () => { + it('wraps passed input', () => { instance = new Choices(input); expect(instance.passedElement).to.be.an.instanceof(WrappedSelect); }); - it('should open the choice list on focusing', () => { + it('opens the choice list on focusing', () => { instance = new Choices(input); instance.input.element.focus(); expect(instance.dropdown.element.classList.contains(instance.config.classNames.activeState)).to.be.true; }); - it('should select the first choice', () => { + it('selects the first choice', () => { instance = new Choices(input); expect(instance.currentState.items[0].value).to.include('Value 1'); }); - it('should highlight the choices on keydown', () => { + it('highlights the choices on keydown', () => { instance = new Choices(input, { renderChoiceLimit: -1, }); @@ -337,7 +337,7 @@ describe('Choices', () => { expect(instance.highlightPosition).to.equal(2); }); - it('should select choice on enter key press', () => { + it('selects choice on enter key press', () => { instance = new Choices(input); instance.input.element.focus(); @@ -360,7 +360,7 @@ describe('Choices', () => { expect(instance.currentState.items.length).to.equal(2); }); - it('should trigger add/change event on selection', () => { + it('triggers add/change event on selection', () => { instance = new Choices(input); const onChangeStub = sinon.stub(); @@ -394,7 +394,7 @@ describe('Choices', () => { expect(addSpyStub.callCount).to.equal(1); }); - it('should open the dropdown on click', () => { + it('opens the dropdown on click', () => { instance = new Choices(input); const container = instance.containerOuter.element; instance._onClick({ @@ -406,7 +406,7 @@ describe('Choices', () => { expect(document.activeElement === instance.input.element && container.classList.contains('is-open')).to.be.true; }); - it('should close the dropdown on double click', () => { + it('closes the dropdown on double click', () => { instance = new Choices(input); const container = instance.containerOuter.element; const openState = instance.config.classNames.openState; @@ -426,7 +426,7 @@ describe('Choices', () => { expect(document.activeElement === instance.input.element && container.classList.contains(openState)).to.be.false; }); - it('should trigger showDropdown on dropdown opening', () => { + it('triggers showDropdown on dropdown opening', () => { instance = new Choices(input); const container = instance.containerOuter.element; @@ -446,7 +446,7 @@ describe('Choices', () => { expect(showDropdownStub.callCount).to.equal(1); }); - it('should trigger hideDropdown on dropdown closing', () => { + it('triggers hideDropdown on dropdown closing', () => { instance = new Choices(input); const container = instance.containerOuter.element; @@ -472,7 +472,7 @@ describe('Choices', () => { expect(hideDropdownStub.callCount).to.equal(1); }); - it('should filter choices when searching', () => { + it('filters choices when searching', () => { instance = new Choices(input); const onSearchStub = sinon.spy(); @@ -496,7 +496,7 @@ describe('Choices', () => { expect(onSearchStub.callCount).to.equal(1); }); - it('shouldn\'t filter choices when searching', () => { + it('doesn\'t filter choices when searching', () => { instance = new Choices(input, { searchChoices: false, }); @@ -524,7 +524,7 @@ describe('Choices', () => { expect(onSearchStub.callCount).to.equal(1); }); - it('shouldn\'t sort choices if shouldSort is false', () => { + it('doesn\'t sort choices if shouldSort is false', () => { instance = new Choices(input, { shouldSort: false, choices: [ @@ -544,7 +544,7 @@ describe('Choices', () => { expect(instance.currentState.choices[0].value).to.equal('Value 5'); }); - it('should sort choices if shouldSort is true', () => { + it('sorts choices if shouldSort is true', () => { instance = new Choices(input, { shouldSort: true, choices: [ @@ -565,7 +565,7 @@ describe('Choices', () => { }); }); - describe('should accept multiple select inputs', () => { + describe('multiple select inputs', () => { let input; let instance; @@ -613,24 +613,24 @@ describe('Choices', () => { instance.destroy(); }); - it('should wrap passed input', () => { + it('wraps passed input', () => { expect(instance.passedElement).to.be.an.instanceof(WrappedSelect); }); - it('should add any pre-defined values', () => { + it('adds any pre-defined values', () => { expect(instance.currentState.items.length).to.be.above(1); }); - it('should add options defined in the config + pre-defined options', () => { + it('adds options defined in the config + pre-defined options', () => { expect(instance.currentState.choices.length).to.equal(6); }); - it('should add a placeholder defined in the config to the search input', () => { + it('adds a placeholder defined in the config to the search input', () => { expect(instance.input.element.placeholder).to.equal('Placeholder text'); }); }); - describe('should handle public methods on select input types', () => { + describe('handles public methods on select input types', () => { let input; let instance; @@ -661,7 +661,7 @@ describe('Choices', () => { instance.destroy(); }); - it('should handle highlightItem()', () => { + it('handles highlightItem()', () => { const items = instance.currentState.items; const randomItem = items[Math.floor(Math.random() * items.length)]; @@ -670,7 +670,7 @@ describe('Choices', () => { expect(randomItem.highlighted).to.be.true; }); - it('should handle unhighlightItem()', () => { + it('handles unhighlightItem()', () => { const items = instance.currentState.items; const randomItem = items[Math.floor(Math.random() * items.length)]; @@ -679,7 +679,7 @@ describe('Choices', () => { expect(randomItem.highlighted).to.be.false; }); - it('should handle highlightAll()', () => { + it('handles highlightAll()', () => { const items = instance.currentState.items; instance.highlightAll(); @@ -689,7 +689,7 @@ describe('Choices', () => { expect(unhighlightedItems).to.be.false; }); - it('should handle unhighlightAll()', () => { + it('handles unhighlightAll()', () => { const items = instance.currentState.items; instance.unhighlightAll(); @@ -699,7 +699,7 @@ describe('Choices', () => { expect(highlightedItems).to.be.false; }); - it('should handle removeHighlightedItems()', () => { + it('handles removeHighlightedItems()', () => { const items = instance.currentState.items; instance.highlightAll(); instance.removeHighlightedItems(); @@ -709,7 +709,7 @@ describe('Choices', () => { expect(activeItems).to.be.false; }); - it('should handle showDropdown()', () => { + it('handles showDropdown()', () => { instance.showDropdown(); const hasOpenState = instance.containerOuter.element.classList.contains(instance.config.classNames.openState); @@ -719,7 +719,7 @@ describe('Choices', () => { expect(hasOpenState && hasAttr && hasActiveState).to.be.true; }); - it('should handle hideDropdown()', () => { + it('handles hideDropdown()', () => { instance.showDropdown(); instance.hideDropdown(); @@ -731,14 +731,14 @@ describe('Choices', () => { expect(hasOpenState && hasAttr && hasActiveState).to.be.false; }); - it('should handle toggleDropdown()', () => { + it('handles toggleDropdown()', () => { sinon.spy(instance, 'hideDropdown'); instance.showDropdown(); instance.toggleDropdown(); expect(instance.hideDropdown.callCount).to.equal(1); }); - it('should handle getValue()', () => { + it('handles getValue()', () => { const valueObjects = instance.getValue(); const valueStrings = instance.getValue(true); @@ -748,7 +748,7 @@ describe('Choices', () => { expect(valueObjects.length).to.equal(5); }); - it('should handle setValue()', () => { + it('handles setValue()', () => { instance.setValue(['Set value 1', 'Set value 2', 'Set value 3']); const valueStrings = instance.getValue(true); @@ -757,7 +757,7 @@ describe('Choices', () => { expect(valueStrings[valueStrings.length - 3]).to.equal('Set value 1'); }); - it('should handle setValueByChoice()', () => { + it('handles setValueByChoice()', () => { const choices = instance.store.getChoicesFilteredByActive(); const randomChoice = choices[Math.floor(Math.random() * choices.length)]; @@ -770,7 +770,7 @@ describe('Choices', () => { expect(value[0]).to.equal(randomChoice.value); }); - it('should handle setChoices()', () => { + it('handles setChoices()', () => { instance.setChoices([{ label: 'Group one', id: 1, @@ -818,7 +818,7 @@ describe('Choices', () => { expect(choices[choices.length - 2].value).to.equal('Child Five'); }); - it('should handle setChoices() with blank values', () => { + it('handles setChoices() with blank values', () => { instance.setChoices([{ label: 'Choice one', value: 'one', @@ -833,7 +833,7 @@ describe('Choices', () => { expect(choices[1].value).to.equal(''); }); - it('should handle clearStore()', () => { + it('handles clearStore()', () => { instance.clearStore(); expect(instance.currentState.items).to.have.lengthOf(0); @@ -841,7 +841,7 @@ describe('Choices', () => { expect(instance.currentState.groups).to.have.lengthOf(0); }); - it('should handle disable()', () => { + it('handles disable()', () => { instance.disable(); expect(instance.input.element.disabled).to.be.true; @@ -853,7 +853,7 @@ describe('Choices', () => { expect(instance.containerOuter.element.getAttribute('aria-disabled')).to.equal('true'); }); - it('should handle enable()', () => { + it('handles enable()', () => { instance.enable(); expect(instance.input.element.disabled).to.be.false; @@ -865,7 +865,7 @@ describe('Choices', () => { expect(instance.containerOuter.element.hasAttribute('aria-disabled')).to.be.false; }); - it('should handle ajax()', () => { + it('handles ajax()', () => { const dummyFn = sinon.spy(); instance.ajax(dummyFn); @@ -874,7 +874,7 @@ describe('Choices', () => { }); }); - describe('should handle public methods on select-one input types', () => { + describe('handles public methods on select-one input types', () => { let input; let instance; @@ -904,20 +904,20 @@ describe('Choices', () => { instance.destroy(); }); - it('should handle disable()', () => { + it('handles disable()', () => { instance.disable(); expect(instance.containerOuter.element.getAttribute('tabindex')).to.equal('-1'); }); - it('should handle enable()', () => { + it('handles enable()', () => { instance.enable(); expect(instance.containerOuter.element.getAttribute('tabindex')).to.equal('0'); }); }); - describe('should handle public methods on text input types', () => { + describe('handles public methods on text input types', () => { let input; let instance; @@ -935,12 +935,12 @@ describe('Choices', () => { instance.destroy(); }); - it('should handle clearInput()', () => { + it('handles clearInput()', () => { instance.clearInput(); expect(instance.input.element.value).to.equal(''); }); - it('should handle removeItemsByValue()', () => { + it('handles removeItemsByValue()', () => { const items = instance.currentState.items; const randomItem = items[Math.floor(Math.random() * items.length)]; @@ -949,7 +949,7 @@ describe('Choices', () => { }); }); - describe('should react to config options', () => { + describe('reacts to config options', () => { let input; let instance; @@ -1056,7 +1056,7 @@ describe('Choices', () => { }); }); - describe('should allow custom properties provided by the user on items or choices', () => { + describe('allows custom properties provided by the user on items or choices', () => { let input; let instance; @@ -1072,7 +1072,7 @@ describe('Choices', () => { instance.destroy(); }); - it('should allow the user to supply custom properties for a choice that will be inherited by the item when the user selects the choice', () => { + it('allows the user to supply custom properties for a choice that will be inherited by the item when the user selects the choice', () => { const expectedCustomProperties = { isBestOptionEver: true, }; @@ -1093,7 +1093,7 @@ describe('Choices', () => { expect(selectedItems[0].customProperties).to.equal(expectedCustomProperties); }); - it('should allow the user to supply custom properties when directly creating a selected item', () => { + it('allows the user to supply custom properties when directly creating a selected item', () => { const expectedCustomProperties = { isBestOptionEver: true, }; diff --git a/src/scripts/src/components/container.js b/src/scripts/src/components/container.js index 2cfaea6..f72ac56 100644 --- a/src/scripts/src/components/container.js +++ b/src/scripts/src/components/container.js @@ -13,6 +13,10 @@ export default class Container { this.onBlur = this.onBlur.bind(this); } + getElement() { + return this.element; + } + /** * Add event listeners */ @@ -155,6 +159,16 @@ export default class Container { this.isDisabled = true; } + revert(originalElement) { + // Move passed element back to original position + this.element.parentNode.insertBefore( + originalElement, + this.element, + ); + // Remove container + this.element.parentNode.removeChild(this.element); + } + /** * Add loading state to element */ diff --git a/src/scripts/src/components/dropdown.js b/src/scripts/src/components/dropdown.js index 264e7bc..a70f09b 100644 --- a/src/scripts/src/components/dropdown.js +++ b/src/scripts/src/components/dropdown.js @@ -8,6 +8,10 @@ export default class Dropdown { this.isActive = false; } + getElement() { + return this.element; + } + /** * Determine how far the top of our element is from * the top of the window diff --git a/src/scripts/src/components/input.js b/src/scripts/src/components/input.js index 12d4ab0..d98fe37 100644 --- a/src/scripts/src/components/input.js +++ b/src/scripts/src/components/input.js @@ -15,6 +15,10 @@ export default class Input { this.onBlur = this.onBlur.bind(this); } + getElement() { + return this.element; + } + addEventListeners() { this.element.addEventListener('input', this.onInput); this.element.addEventListener('paste', this.onPaste); diff --git a/src/scripts/src/components/list.js b/src/scripts/src/components/list.js index 27d0f06..b8ba080 100644 --- a/src/scripts/src/components/list.js +++ b/src/scripts/src/components/list.js @@ -8,6 +8,10 @@ export default class List { this.hasChildren = !!this.element.children; } + getElement() { + return this.element; + } + /** * Clear List contents */ diff --git a/src/scripts/src/components/wrapped-element.js b/src/scripts/src/components/wrapped-element.js index f5bfa3d..df2772b 100644 --- a/src/scripts/src/components/wrapped-element.js +++ b/src/scripts/src/components/wrapped-element.js @@ -1,8 +1,19 @@ +import { dispatchEvent } from '../lib/utils'; + export default class WrappedElement { constructor(instance, element, classNames) { this.parentInstance = instance; this.element = element; this.classNames = classNames; + this.isDisabled = false; + } + + getElement() { + return this.element; + } + + getValue() { + return this.element.value; } conceal() { @@ -50,4 +61,20 @@ export default class WrappedElement { // Re-assign values - this is weird, I know this.element.value = this.element.value; } + + enable() { + this.element.removeAttribute('disabled'); + this.element.disabled = false; + this.isDisabled = false; + } + + disable() { + this.element.setAttribute('disabled', ''); + this.element.disabled = true; + this.isDisabled = true; + } + + triggerEvent(eventType, data) { + dispatchEvent(this.element, eventType, data); + } } diff --git a/src/scripts/src/components/wrapped-input.js b/src/scripts/src/components/wrapped-input.js index 7254395..34d24ef 100644 --- a/src/scripts/src/components/wrapped-input.js +++ b/src/scripts/src/components/wrapped-input.js @@ -8,6 +8,10 @@ export default class WrappedInput extends WrappedElement { this.classNames = classNames; } + getElement() { + super.getElement(); + } + conceal() { super.conceal(); } @@ -15,4 +19,17 @@ export default class WrappedInput extends WrappedElement { reveal() { super.reveal(); } + + enable() { + super.enable(); + } + + disable() { + super.enable(); + } + + setValue(value) { + this.element.setAttribute('value', value); + this.element.value = value; + } } diff --git a/src/scripts/src/components/wrapped-select.js b/src/scripts/src/components/wrapped-select.js index 6fb4a71..c7594ca 100644 --- a/src/scripts/src/components/wrapped-select.js +++ b/src/scripts/src/components/wrapped-select.js @@ -8,6 +8,10 @@ export default class WrappedSelect extends WrappedElement { this.classNames = classNames; } + getElement() { + super.getElement(); + } + conceal() { super.conceal(); } @@ -15,4 +19,29 @@ export default class WrappedSelect extends WrappedElement { reveal() { super.reveal(); } + + enable() { + super.enable(); + } + + disable() { + super.enable(); + } + + setOptions(options) { + this.element.innerHTML = ''; + this.element.appendChild(options); + } + + getPlaceholderOption() { + return this.element.querySelector('option[placeholder]'); + } + + getOptions() { + return Array.from(this.element.options); + } + + getOptionGroups() { + return Array.from(this.element.getElementsByTagName('OPTGROUP')); + } } diff --git a/src/scripts/src/constants.js b/src/scripts/src/constants.js index e0878ad..3b0d88a 100644 --- a/src/scripts/src/constants.js +++ b/src/scripts/src/constants.js @@ -103,3 +103,5 @@ export const KEY_CODES = { PAGE_UP_KEY: 33, PAGE_DOWN_KEY: 34, }; + +export const SCROLLING_SPEED = 4; diff --git a/src/scripts/src/lib/utils.js b/src/scripts/src/lib/utils.js index 5756b03..26c68d0 100644 --- a/src/scripts/src/lib/utils.js +++ b/src/scripts/src/lib/utils.js @@ -552,13 +552,13 @@ export const sortByAlpha = (a, b) => { export const sortByScore = (a, b) => a.score - b.score; /** - * Trigger native event + * Dispatch native event * @param {NodeElement} element Element to trigger event on * @param {String} type Type of event to trigger * @param {Object} customArgs Data to pass with event * @return {Object} Triggered event */ -export const triggerEvent = (element, type, customArgs = null) => { +export const dispatchEvent = (element, type, customArgs = null) => { const event = new CustomEvent(type, { detail: customArgs, bubbles: true, diff --git a/src/scripts/src/templates.js b/src/scripts/src/templates.js index 05f14ba..584cc17 100644 --- a/src/scripts/src/templates.js +++ b/src/scripts/src/templates.js @@ -2,12 +2,19 @@ import classNames from 'classnames'; import { strToEl } from './lib/utils'; export const TEMPLATES = { - containerOuter(globalClasses, direction) { - const tabIndex = this.isSelectOneElement ? 'tabindex="0"' : ''; - let role = this.isSelectElement ? 'role="listbox"' : ''; + containerOuter( + globalClasses, + direction, + isSelectElement, + isSelectOneElement, + searchEnabled, + passedElementType, + ) { + const tabIndex = isSelectOneElement ? 'tabindex="0"' : ''; + let role = isSelectElement ? 'role="listbox"' : ''; let ariaAutoComplete = ''; - if (this.isSelectElement && this.config.searchEnabled) { + if (isSelectElement && searchEnabled) { role = 'role="combobox"'; ariaAutoComplete = 'aria-autocomplete="list"'; } @@ -15,7 +22,7 @@ export const TEMPLATES = { return strToEl(`
`); }, - itemList(globalClasses) { + itemList(globalClasses, isSelectOneElement) { const localClasses = classNames( globalClasses.list, { - [globalClasses.listSingle]: (this.isSelectOneElement), - [globalClasses.listItems]: (!this.isSelectOneElement), + [globalClasses.listSingle]: (isSelectOneElement), + [globalClasses.listItems]: (!isSelectOneElement), }, ); @@ -50,7 +57,7 @@ export const TEMPLATES = { `); }, - item(globalClasses, data) { + item(globalClasses, data, removeItemButton) { const ariaSelected = data.active ? 'aria-selected="true"' : ''; const ariaDisabled = data.disabled ? 'aria-disabled="true"' : ''; @@ -62,7 +69,7 @@ export const TEMPLATES = { }, ); - if (this.config.removeItemButton) { + if (removeItemButton) { localClasses = classNames( globalClasses.item, { [globalClasses.highlightedState]: data.highlighted, @@ -107,8 +114,8 @@ export const TEMPLATES = { `); }, - choiceList(globalClasses) { - const ariaMultiSelectable = !this.isSelectOneElement ? + choiceList(globalClasses, isSelectOneElement) { + const ariaMultiSelectable = !isSelectOneElement ? 'aria-multiselectable="true"' : ''; @@ -143,7 +150,7 @@ export const TEMPLATES = { `); }, - choice(globalClasses, data) { + choice(globalClasses, data, itemSelectText) { const role = data.groupId > 0 ? 'role="treeitem"' : 'role="option"'; const localClasses = classNames( globalClasses.item, @@ -157,7 +164,7 @@ export const TEMPLATES = { return strToEl(`