diff --git a/.eslintrc b/.eslintrc index cd14843..51bfd7c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,19 @@ "mocha": true, "jasmine": true }, + "globals": { + "__DEV__": true, + "describe": true, + "it": true, + "before": true, + "after": true, + "beforeEach": true, + "afterEach": true, + "expect": true, + "browser": true, + "by": true, + "element": true + }, "parser": "babel-eslint", "rules": { "strict": 0, diff --git a/.gitignore b/.gitignore index c873575..744b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ package-lock.json # Test tests/reports tests/results +.nyc_output +coverage diff --git a/config/test.js b/config/test.js new file mode 100644 index 0000000..2da2edd --- /dev/null +++ b/config/test.js @@ -0,0 +1,49 @@ +const { JSDOM } = require('jsdom'); + +const jsdom = new JSDOM(''); +const { window } = jsdom; + +function copyProps(src, target) { + const props = Object.getOwnPropertyNames(src) + .filter(prop => typeof target[prop] === 'undefined') + .map(prop => Object.getOwnPropertyDescriptor(src, prop)); + Object.defineProperties(target, props); +} + +function ignoreExtensions(extensions = [], returnValue = {}) { + function noop() { + return returnValue; + } + + extensions.forEach((ext) => { + require.extensions[ext] = noop; + }); +} + +function mockStorage() { + return { + removeItem: function(key) { + delete this[key]; + }, + getItem: function(key) { + return this[key]; + }, + setItem: function(key, value) { + this[key] = value; + }, + clear: function() {} + } +} + +global.window = window; +global.document = window.document; +global.navigator = { + userAgent: 'node.js' +}; +global.HTMLElement = window.HTMLElement; +global.window.localStorage = mockStorage; +global.window.sessionStorage = mockStorage; + +copyProps(window, global); +ignoreExtensions(['.scss', '.css']); +ignoreExtensions(['.jpg', '.png', '.svg'], ''); diff --git a/package.json b/package.json index c517e03..c6906d8 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,20 @@ "name": "choices.js", "version": "3.0.2", "description": "A vanilla JS customisable text input/select box plugin", - "main": "./assets/scripts/dist/choices.min.js", + "main": "./src/scripts/dist/choices.min.js", "types": "index.d.ts", "scripts": { "start": "node server.js", "lint": "eslint assets/**/*.js", "test": "karma start --single-run --no-auto-watch tests/karma.config.js", "test:watch": "karma start --auto-watch --no-single-run tests/karma.config.js", + "test:mocha": "nyc mocha --require ./config/test.js --compilers babel-core/register \"./src/**/**/**/**/*.spec.js\"", + "test:mocha:watch": "npm run test:mocha -- --watch", "css:watch": "nodemon -e scss -x \"npm run css:build\"", "css:build": "npm run css:sass -s && npm run css:prefix -s && npm run css:min -s", - "css:sass": "node-sass --output-style expanded --include-path scss assets/styles/scss/base.scss assets/styles/css/base.css && node-sass --output-style expanded --include-path scss assets/styles/scss/choices.scss assets/styles/css/choices.css", - "css:prefix": "postcss --use autoprefixer -b 'last 2 versions' assets/styles/css/*.css -d assets/styles/css/", - "css:min": "csso assets/styles/css/base.css assets/styles/css/base.min.css && csso assets/styles/css/choices.css assets/styles/css/choices.min.css", + "css:sass": "node-sass --output-style expanded --include-path scss src/styles/scss/base.scss src/styles/css/base.css && node-sass --output-style expanded --include-path scss src/styles/scss/choices.scss src/styles/css/choices.css", + "css:prefix": "postcss --use autoprefixer -b 'last 2 versions' src/styles/css/*.css -d src/styles/css/", + "css:min": "csso src/styles/css/base.css src/styles/css/base.min.css && csso src/styles/css/choices.css src/styles/css/choices.min.css", "js:build": "concurrently --prefix-colors yellow,green \"webpack --minimize --config webpack.config.prod.js\" \"webpack --config webpack.config.prod.js\"", "version": "node version.js --current $npm_package_version --new $npm_config_newVersion", "postversion": "npm run js:build", @@ -31,13 +33,15 @@ "homepage": "https://github.com/jshjohnson/Choices#readme", "devDependencies": { "autoprefixer": "^6.3.3", - "babel-core": "^6.7.2", + "babel-core": "^6.25.0", "babel-eslint": "^7.2.3", "babel-loader": "^6.2.4", "babel-preset-es2015": "^6.6.0", + "chai": "^4.1.0", "concurrently": "^3.1.0", "core-js": "^2.4.1", "csso": "^1.8.2", + "custom-event-autopolyfill": "^0.1.3", "es6-promise": "^3.2.1", "eslint": "^3.19.0", "eslint-config-airbnb": "^15.1.0", @@ -46,6 +50,7 @@ "eslint-plugin-jsx-a11y": "^5.1.1", "eslint-plugin-react": "^7.2.1", "jasmine-core": "2.4.1", + "jsdom": "^11.1.0", "karma": "^1.1.0", "karma-coverage": "^1.0.0", "karma-es6-shim": "^1.0.0", @@ -54,10 +59,13 @@ "karma-phantomjs-launcher": "^1.0.1", "karma-spec-reporter": "0.0.26", "karma-webpack": "^1.7.0", + "mocha": "^3.4.2", "node-sass": "^3.4.2", "nodemon": "^1.9.1", + "nyc": "^11.0.3", "opn-cli": "^3.1.0", "postcss-cli": "^2.5.1", + "sinon": "^2.4.0", "webpack": "^1.12.14", "webpack-dashboard": "^0.1.8", "webpack-dev-server": "^1.14.1", @@ -72,12 +80,20 @@ "npmName": "choices.js", "npmFileMap": [ { - "basePath": "assets", + "basePath": "src", "files": [ "scripts/dist/*", "styles/css/*", "icons/*" ] } - ] + ], + "nyc": { + "include": [ + "src/**/**/**/**/**/*.js" + ], + "exclude": [ + "src/**/**/**/**/**/*.spec.js" + ] + } } diff --git a/assets/icons/cross-inverse.svg b/src/icons/cross-inverse.svg similarity index 100% rename from assets/icons/cross-inverse.svg rename to src/icons/cross-inverse.svg diff --git a/assets/icons/cross.svg b/src/icons/cross.svg similarity index 100% rename from assets/icons/cross.svg rename to src/icons/cross.svg diff --git a/assets/images/android-chrome-192x192.png b/src/images/android-chrome-192x192.png similarity index 100% rename from assets/images/android-chrome-192x192.png rename to src/images/android-chrome-192x192.png diff --git a/assets/images/apple-touch-icon.png b/src/images/apple-touch-icon.png similarity index 100% rename from assets/images/apple-touch-icon.png rename to src/images/apple-touch-icon.png diff --git a/assets/images/browserconfig.xml b/src/images/browserconfig.xml similarity index 100% rename from assets/images/browserconfig.xml rename to src/images/browserconfig.xml diff --git a/assets/images/favicon-16x16.png b/src/images/favicon-16x16.png similarity index 100% rename from assets/images/favicon-16x16.png rename to src/images/favicon-16x16.png diff --git a/assets/images/favicon-32x32.png b/src/images/favicon-32x32.png similarity index 100% rename from assets/images/favicon-32x32.png rename to src/images/favicon-32x32.png diff --git a/assets/images/favicon.ico b/src/images/favicon.ico similarity index 100% rename from assets/images/favicon.ico rename to src/images/favicon.ico diff --git a/assets/images/favicon.png b/src/images/favicon.png similarity index 100% rename from assets/images/favicon.png rename to src/images/favicon.png diff --git a/assets/images/logo.svg b/src/images/logo.svg similarity index 100% rename from assets/images/logo.svg rename to src/images/logo.svg diff --git a/assets/images/manifest.json b/src/images/manifest.json similarity index 100% rename from assets/images/manifest.json rename to src/images/manifest.json diff --git a/assets/images/mstile-150x150.png b/src/images/mstile-150x150.png similarity index 100% rename from assets/images/mstile-150x150.png rename to src/images/mstile-150x150.png diff --git a/assets/images/safari-pinned-tab.svg b/src/images/safari-pinned-tab.svg similarity index 100% rename from assets/images/safari-pinned-tab.svg rename to src/images/safari-pinned-tab.svg diff --git a/assets/scripts/dist/choices.js b/src/scripts/dist/choices.js similarity index 100% rename from assets/scripts/dist/choices.js rename to src/scripts/dist/choices.js diff --git a/assets/scripts/dist/choices.js.map b/src/scripts/dist/choices.js.map similarity index 100% rename from assets/scripts/dist/choices.js.map rename to src/scripts/dist/choices.js.map diff --git a/assets/scripts/dist/choices.min.js b/src/scripts/dist/choices.min.js similarity index 100% rename from assets/scripts/dist/choices.min.js rename to src/scripts/dist/choices.min.js diff --git a/src/scripts/dist/choices.min.js.map b/src/scripts/dist/choices.min.js.map new file mode 100644 index 0000000..064a38d --- /dev/null +++ b/src/scripts/dist/choices.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"choices.min.js","sources":[],"mappings":";;;","sourceRoot":""} \ No newline at end of file diff --git a/assets/scripts/src/actions/index.js b/src/scripts/src/actions/actions.js similarity index 100% rename from assets/scripts/src/actions/index.js rename to src/scripts/src/actions/actions.js diff --git a/assets/scripts/src/choices.js b/src/scripts/src/choices.js similarity index 99% rename from assets/scripts/src/choices.js rename to src/scripts/src/choices.js index cc7cd03..4d29706 100644 --- a/assets/scripts/src/choices.js +++ b/src/scripts/src/choices.js @@ -1,6 +1,6 @@ import Fuse from 'fuse.js'; import classNames from 'classnames'; -import Store from './store/index'; +import Store from './store/store'; import Dropdown from './components/dropdown'; import Container from './components/container'; import Input from './components/input'; @@ -15,7 +15,7 @@ import { addGroup, clearAll, clearChoices, -} from './actions/index'; +} from './actions/actions'; import { isScrolledIntoView, getAdjacentEl, diff --git a/src/scripts/src/choices.spec.js b/src/scripts/src/choices.spec.js new file mode 100644 index 0000000..4b358fd --- /dev/null +++ b/src/scripts/src/choices.spec.js @@ -0,0 +1,1190 @@ +import 'whatwg-fetch'; +import 'es6-promise'; +import 'core-js/fn/object/assign'; +import 'custom-event-autopolyfill'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import Choices from './choices.js'; +import itemReducer from './reducers/items.js'; +import choiceReducer from './reducers/choices.js'; +import { + addItem as addItemAction, + addChoice as addChoiceAction +} from './actions/actions.js'; + +describe('Choices', () => { + describe('should initialize Choices', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'js-choices'; + + document.body.appendChild(input); + instance = new Choices(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should be defined', () => { + expect(instance).not.be.undefined; + }); + + it('should have initialised', () => { + expect(instance.initialised).to.be.true; + }); + + it('should not re-initialise if passed element again', () => { + const reinitialise = new Choices(instance.passedElement); + sinon.spy(reinitialise, '_createTemplates'); + expect(reinitialise._createTemplates.callCount).to.equal(0); + }); + + it('should have 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', () => { + expect(instance.config.silent).to.be.a('boolean'); + expect(instance.config.items).to.be.an('array'); + expect(instance.config.choices).to.be.an('array'); + expect(instance.config.renderChoiceLimit).to.be.a('number'); + expect(instance.config.maxItemCount).to.be.a('number'); + expect(instance.config.addItems).to.be.a('boolean'); + expect(instance.config.removeItems).to.be.a('boolean'); + expect(instance.config.removeItemButton).to.be.a('boolean'); + expect(instance.config.editItems).to.be.a('boolean'); + expect(instance.config.duplicateItems).to.be.a('boolean'); + expect(instance.config.delimiter).to.be.a('string'); + expect(instance.config.paste).to.be.a('boolean'); + expect(instance.config.searchEnabled).to.be.a('boolean'); + expect(instance.config.searchChoices).to.be.a('boolean'); + expect(instance.config.searchFloor).to.be.a('number'); + expect(instance.config.searchResultLimit).to.be.a('number'); + // expect(instance.config.searchFields).to.equal(jasmine.any(Array) || jasmine.any(String)); + expect(instance.config.position).to.be.a('string'); + expect(instance.config.regexFilter).to.be.null; + expect(instance.config.sortFilter).to.be.a('function'); + expect(instance.config.shouldSort).to.be.a('boolean'); + expect(instance.config.shouldSortItems).to.be.a('boolean'); + expect(instance.config.placeholder).to.be.a('boolean'); + expect(instance.config.placeholderValue).to.be.null; + expect(instance.config.prependValue).to.be.null; + expect(instance.config.appendValue).to.be.null; + expect(instance.config.renderSelectedChoices).to.be.a('string'); + expect(instance.config.loadingText).to.be.a('string'); + expect(instance.config.noResultsText).to.be.a('string'); + expect(instance.config.noChoicesText).to.be.a('string'); + expect(instance.config.itemSelectText).to.be.a('string'); + expect(instance.config.classNames).to.be.an('object'); + expect(instance.config.callbackOnInit).to.be.null; + expect(instance.config.callbackOnCreateTemplates).to.be.null; + }); + + it('should expose public methods', () => { + expect(instance.init).to.be.a('function'); + expect(instance.destroy).to.be.a('function'); + expect(instance.render).to.be.a('function'); + expect(instance.renderGroups).to.be.a('function'); + expect(instance.renderItems).to.be.a('function'); + expect(instance.renderChoices).to.be.a('function'); + expect(instance.highlightItem).to.be.a('function'); + expect(instance.unhighlightItem).to.be.a('function'); + expect(instance.highlightAll).to.be.a('function'); + expect(instance.unhighlightAll).to.be.a('function'); + expect(instance.removeItemsByValue).to.be.a('function'); + expect(instance.removeActiveItems).to.be.a('function'); + expect(instance.removeHighlightedItems).to.be.a('function'); + expect(instance.showDropdown).to.be.a('function'); + expect(instance.hideDropdown).to.be.a('function'); + expect(instance.toggleDropdown).to.be.a('function'); + expect(instance.getValue).to.be.a('function'); + expect(instance.setValue).to.be.a('function'); + expect(instance.setValueByChoice).to.be.a('function'); + expect(instance.setChoices).to.be.a('function'); + expect(instance.disable).to.be.a('function'); + expect(instance.enable).to.be.a('function'); + expect(instance.ajax).to.be.a('function'); + expect(instance.clearStore).to.be.a('function'); + expect(instance.clearInput).to.be.a('function'); + }); + + it('should hide passed input', () => { + expect(instance.passedElement.style.display).to.equal('none'); + }); + + it('should create an outer container', () => { + expect(instance.containerOuter).to.be.an.instanceof(HTMLElement); + }); + + it('should create an inner container', () => { + expect(instance.containerInner).to.be.an.instanceof(HTMLElement); + }); + + it('should create a choice list', () => { + expect(instance.choiceList).to.be.an.instanceof(HTMLElement); + }); + + it('should create an item list', () => { + expect(instance.itemList).to.be.an.instanceof(HTMLElement); + }); + + it('should create an input', () => { + expect(instance.input).to.be.an.instanceof(HTMLElement); + }); + + it('should create a dropdown', () => { + expect(instance.dropdown).to.be.an.instanceof(HTMLElement); + }); + + it('should backup and recover original styles', () => { + const origStyle = 'background-color: #ccc; margin: 5px padding: 10px;'; + + instance.destroy(); + input.setAttribute('style', origStyle); + instance = new Choices(input); + + let style = input.getAttribute('data-choice-orig-style'); + expect(style).to.equal(origStyle); + + instance.destroy(); + style = input.getAttribute('data-choice-orig-style'); + expect(style).to.be.null; + + style = input.getAttribute('style'); + expect(style).to.equal(origStyle); + }); + }); + + describe('should accept text inputs', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'js-choices'; + input.placeholder = 'Placeholder text'; + + document.body.appendChild(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should accept a user inputted value', () => { + instance = new Choices(input); + + instance.input.focus(); + instance.input.value = 'test'; + + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + expect(instance.currentState.items[0].value).to.include(instance.input.value); + }); + + it('should copy the passed placeholder to the cloned input', () => { + instance = new Choices(input); + + expect(instance.input.placeholder).to.equal(input.placeholder); + }); + + it('should not allow duplicates if duplicateItems is false', () => { + instance = new Choices(input, { + duplicateItems: false, + items: ['test 1'], + }); + + instance.input.focus(); + instance.input.value = 'test 1'; + + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + expect(instance.currentState.items[instance.currentState.items.length - 1]).not.to.include(instance.input.value); + }); + + it('should filter 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,}))$/, + }); + + instance.input.focus(); + instance.input.value = 'josh@joshuajohnson.co.uk'; + + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + instance.input.focus(); + instance.input.value = 'not an email address'; + + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + const lastItem = instance.currentState.items[instance.currentState.items.length - 1]; + + expect(lastItem.value).to.equal('josh@joshuajohnson.co.uk'); + expect(lastItem.value).not.to.equal('not an email address'); + }); + + it('should prepend and append values if passed', () => { + instance = new Choices(input, { + prependValue: 'item-', + appendValue: '-value', + }); + + instance.input.focus(); + instance.input.value = 'test'; + + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + const lastItem = instance.currentState.items[instance.currentState.items.length - 1]; + + expect(lastItem.value).not.to.equal('test'); + expect(lastItem.value).to.equal('item-test-value'); + }); + }); + + describe('should accept single select inputs', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.placeholder = 'Placeholder text'; + + for (let i = 1; i < 4; i++) { + const option = document.createElement('option'); + + option.value = `Value ${i}`; + option.innerHTML = `Label ${i}`; + + input.appendChild(option); + } + + document.body.appendChild(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should open the choice list on focusing', () => { + instance = new Choices(input); + instance.input.focus(); + expect(instance.dropdown.classList).to.include(instance.config.classNames.activeState); + }); + + it('should select the first choice', () => { + instance = new Choices(input); + expect(instance.currentState.items[0].value).to.include('Value 1'); + }); + + it('should highlight the choices on keydown', () => { + instance = new Choices(input, { + renderChoiceLimit: -1 + }); + instance.input.focus(); + + for (let i = 0; i < 2; i++) { + // Key down to third choice + instance._onKeyDown({ + target: instance.input, + keyCode: 40, + ctrlKey: false, + preventDefault: () => {} + }); + } + + expect(instance.highlightPosition).to.equal(2); + }); + + it('should select choice on enter key press', () => { + instance = new Choices(input); + instance.input.focus(); + + // Key down to second choice + instance._onKeyDown({ + target: instance.input, + keyCode: 40, + ctrlKey: false, + preventDefault: () => {} + }); + + // Key down to select choice + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false, + preventDefault: () => {} + }); + + expect(instance.currentState.items.length).to.equal(2); + }); + + it('should trigger add/change event on selection', () => { + instance = new Choices(input); + + const onChangeStub = sinon.stub(); + const addSpyStub = sinon.stub(); + const passedElement = instance.passedElement; + + passedElement.addEventListener('change', onChangeStub); + passedElement.addEventListener('addItem', addSpyStub); + + instance.input.focus(); + + // Key down to second choice + instance._onKeyDown({ + target: instance.input, + keyCode: 40, + ctrlKey: false, + preventDefault: () => {} + }); + + // Key down to select choice + instance._onKeyDown({ + target: instance.input, + keyCode: 13, + ctrlKey: false, + preventDefault: () => {} + }); + + const returnValue = onChangeStub.calls.mostRecent().args[0].detail.value; + expect(returnValue).to.be.a('string'); + expect(onChangeStub.callCount).to.equal(1); + expect(addSpyStub.callCount).to.equal(1); + }); + + it('should open the dropdown on click', () => { + instance = new Choices(input); + const container = instance.containerOuter; + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + expect(document.activeElement === instance.input && container.classList.contains('is-open')).to.be.true; + }); + + it('should close the dropdown on double click', () => { + instance = new Choices(input); + const container = instance.containerOuter, + openState = instance.config.classNames.openState; + + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + expect(document.activeElement === instance.input && container.classList.contains(openState)).to.be.false; + }); + + it('should trigger showDropdown on dropdown opening', () => { + instance = new Choices(input); + const container = instance.containerOuter; + + const showDropdownStub = sinon.spy(); + const passedElement = instance.passedElement; + + passedElement.addEventListener('showDropdown', showDropdownStub); + + instance.input.focus(); + + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + expect(showDropdownStub.callCount).to.equal(1); + }); + + it('should trigger hideDropdown on dropdown closing', () => { + instance = new Choices(input); + + const container = instance.containerOuter; + const hideDropdownStub = sinon.stub(); + const passedElement = instance.passedElement; + + passedElement.addEventListener('hideDropdown', hideDropdownStub); + + instance.input.focus(); + + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + instance._onClick({ + target: container, + ctrlKey: false, + preventDefault: () => {} + }); + + expect(hideDropdownStub.callCount).to.equal(1); + }); + + it('should filter choices when searching', () => { + instance = new Choices(input); + + const onSearchStub = sinon.spy(); + const passedElement = instance.passedElement; + + passedElement.addEventListener('search', onSearchStub); + + instance.input.focus(); + instance.input.value = '3 '; + + // Key down to search + instance._onKeyUp({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + const mostAccurateResult = instance.currentState.choices.filter((choice) => { + return choice.active; + }); + + expect(instance.isSearching && mostAccurateResult[0].value === 'Value 3').to.be.true; + expect(onSearchStub.callCount).to.equal(1); + }); + + it('shouldn\'t filter choices when searching', () => { + instance = new Choices(input, { + searchChoices: false + }); + + instance.setValue(['Javascript', 'HTML', 'Jasmine']); + + const onSearchStub = sinon.spy(); + const passedElement = instance.passedElement; + + passedElement.addEventListener('search', onSearchStub); + + instance.input.focus(); + instance.input.value = 'Javascript'; + + // Key down to search + instance._onKeyUp({ + target: instance.input, + keyCode: 13, + ctrlKey: false + }); + + const activeOptions = instance.currentState.choices.filter(function (choice) { + return choice.active; + }); + + expect(activeOptions.length).to.equal(instance.currentState.choices.length); + expect(onSearchStub.callCount).to.equal(1); + }); + + it('shouldn\'t sort choices if shouldSort is false', () => { + instance = new Choices(input, { + shouldSort: false, + choices: [ + { + value: 'Value 5', + label: 'Label Five' + }, { + value: 'Value 6', + label: 'Label Six' + }, { + value: 'Value 7', + label: 'Label Seven' + }, + ], + }); + + expect(instance.currentState.choices[0].value).to.equal('Value 5'); + }); + + it('should sort choices if shouldSort is true', () => { + instance = new Choices(input, { + shouldSort: true, + choices: [ + { + value: 'Value 5', + label: 'Label Five' + }, { + value: 'Value 6', + label: 'Label Six' + }, { + value: 'Value 7', + label: 'Label Seven' + }, + ], + }); + + expect(instance.currentState.choices[0].value).to.equal('Value 1'); + }); + }); + + describe('should accept multiple select inputs', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.setAttribute('multiple', ''); + + for (let i = 1; i < 4; i++) { + const option = document.createElement('option'); + + option.value = `Value ${i}`; + option.innerHTML = `Value ${i}`; + + if (i % 2) { + option.selected = true; + } + + input.appendChild(option); + } + + document.body.appendChild(input); + + instance = new Choices(input, { + placeholderValue: 'Placeholder text', + choices: [ + { + value: 'One', + label: 'Label One', + selected: true, + disabled: false + }, { + value: 'Two', + label: 'Label Two', + disabled: true + }, { + value: 'Three', + label: 'Label Three' + }, + ], + }); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should add any pre-defined values', () => { + expect(instance.currentState.items.length).to.be.above(1); + }); + + it('should add 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', () => { + expect(instance.input.placeholder).to.equal('Placeholder text'); + }); + }); + + describe('should handle public methods on select input types', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.multiple = true; + input.placeholder = 'Placeholder text'; + + for (let i = 1; i < 10; i++) { + const option = document.createElement('option'); + + option.value = `Value ${i}`; + option.innerHTML = `Value ${i}`; + + if (i % 2) { + option.selected = true; + } + + input.appendChild(option); + } + + document.body.appendChild(input); + instance = new Choices(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should handle highlightItem()', () => { + const items = instance.currentState.items; + const randomItem = items[Math.floor(Math.random() * items.length)]; + + instance.highlightItem(randomItem); + + expect(randomItem.highlighted).to.be.true; + }); + + it('should handle unhighlightItem()', () => { + const items = instance.currentState.items; + const randomItem = items[Math.floor(Math.random() * items.length)]; + + instance.unhighlightItem(randomItem); + + expect(randomItem.highlighted).to.be.false; + }); + + it('should handle highlightAll()', () => { + const items = instance.currentState.items; + + instance.highlightAll(); + + const unhighlightedItems = items.some((item) => item.highlighted === false); + + expect(unhighlightedItems).to.be.false; + }); + + it('should handle unhighlightAll()', () => { + const items = instance.currentState.items; + + instance.unhighlightAll(); + + const highlightedItems = items.some((item) => item.highlighted === true); + + expect(highlightedItems).to.be.false; + }); + + it('should handle removeHighlightedItems()', () => { + const items = instance.currentState.items; + instance.highlightAll(); + instance.removeHighlightedItems(); + + const activeItems = items.some((item) => item.active === true); + + expect(activeItems).to.be.false; + }); + + it('should handle showDropdown()', () => { + instance.showDropdown(); + + const hasOpenState = instance.containerOuter.classList.contains(instance.config.classNames.openState); + const hasAttr = instance.containerOuter.getAttribute('aria-expanded') === 'true'; + const hasActiveState = instance.dropdown.classList.contains(instance.config.classNames.activeState); + + expect(hasOpenState && hasAttr && hasActiveState).to.be.true; + }); + + it('should handle hideDropdown()', () => { + instance.showDropdown(); + instance.hideDropdown(); + + const hasOpenState = instance.containerOuter.classList.contains(instance.config.classNames.openState); + const hasAttr = instance.containerOuter.getAttribute('aria-expanded') === 'true'; + const hasActiveState = instance.dropdown.classList.contains(instance.config.classNames.activeState); + + expect(hasOpenState && hasAttr && hasActiveState).to.be.false; + }); + + it('should handle toggleDropdown()', () => { + sinon.spy(instance, 'hideDropdown'); + instance.showDropdown(); + instance.toggleDropdown(); + expect(instance.hideDropdown.callCount).to.equal(1); + }); + + it('should handle hideDropdown()', () => { + instance.showDropdown(); + expect(instance.containerOuter.classList).to.include(instance.config.classNames.openState); + }); + + it('should handle getValue()', () => { + const valueObjects = instance.getValue(); + const valueStrings = instance.getValue(true); + + expect(valueStrings[0]).to.be.a('string'); + expect(valueObjects[0]).to.be.an('object'); + expect(valueObjects).to.be.an('array'); + expect(valueObjects.length).to.equal(5); + }); + + it('should handle setValue()', () => { + instance.setValue(['Set value 1', 'Set value 2', 'Set value 3']); + const valueStrings = instance.getValue(true); + + expect(valueStrings[valueStrings.length - 1]).to.equal('Set value 3'); + expect(valueStrings[valueStrings.length - 2]).to.equal('Set value 2'); + expect(valueStrings[valueStrings.length - 3]).to.equal('Set value 1'); + }); + + it('should handle setValueByChoice()', () => { + const choices = instance.store.getChoicesFilteredByActive(); + const randomChoice = choices[Math.floor(Math.random() * choices.length)]; + + instance.highlightAll(); + instance.removeHighlightedItems(); + instance.setValueByChoice(randomChoice.value); + + const value = instance.getValue(true); + + expect(value[0]).to.equal(randomChoice.value); + }); + + it('should handle setChoices()', () => { + instance.setChoices([{ + label: 'Group one', + 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' + }, + ], + }, { + label: 'Group two', + 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', 'label'); + + + const groups = instance.currentState.groups; + const choices = instance.currentState.choices; + + expect(groups[groups.length - 1].value).to.equal('Group two'); + expect(groups[groups.length - 2].value).to.equal('Group one'); + expect(choices[choices.length - 1].value).to.equal('Child Six'); + expect(choices[choices.length - 2].value).to.equal('Child Five'); + }); + + it('should handle setChoices() with blank values', () => { + instance.setChoices([{ + label: 'Choice one', + value: 'one' + }, { + label: 'Choice two', + value: '' + }], 'value', 'label', true); + + + const choices = instance.currentState.choices; + expect(choices[0].value).to.equal('one'); + expect(choices[1].value).to.equal(''); + }); + + it('should handle clearStore()', () => { + instance.clearStore(); + + expect(instance.currentState.items).to.have.lengthOf(0); + expect(instance.currentState.choices).to.have.lengthOf(0); + expect(instance.currentState.groups).to.have.lengthOf(0); + }); + + it('should handle disable()', () => { + instance.disable(); + + expect(instance.input.disabled).to.be.true; + expect(instance.containerOuter.classList.contains(instance.config.classNames.disabledState)).to.be.true; + expect(instance.containerOuter.getAttribute('aria-disabled')).to.equal('true'); + }); + + it('should handle enable()', () => { + instance.enable(); + + expect(instance.input.disabled).to.be.false; + expect(instance.containerOuter.classList.contains(instance.config.classNames.disabledState)).to.be.false; + expect(instance.containerOuter.hasAttribute('aria-disabled')).to.be.false; + }); + + it('should handle ajax()', () => { + sinon.spy(instance, 'ajax'); + + instance.ajax((callback) => { + fetch('https://restcountries.eu/rest/v1/all') + .then((response) => { + response.json().then((data) => { + callback(data, 'alpha2Code', 'name'); + }); + }) + .catch((error) => { + console.log(error); + }); + }); + + expect(instance.ajax).toHaveBeenCalledWith(jasmine.any(Function)); + }); + }); + + describe('should handle public methods on select-one input types', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.placeholder = 'Placeholder text'; + + for (let i = 1; i < 10; i++) { + const option = document.createElement('option'); + + option.value = `Value ${i}`; + option.innerHTML = `Value ${i}`; + + if (i % 2) { + option.selected = true; + } + + input.appendChild(option); + } + + document.body.appendChild(input); + instance = new Choices(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should handle disable()', () => { + instance.disable(); + + expect(instance.containerOuter.getAttribute('tabindex')).to.equal('-1'); + }); + + it('should handle enable()', () => { + instance.enable(); + + expect(instance.containerOuter.getAttribute('tabindex')).to.equal('0'); + }); + }); + + describe('should handle public methods on text input types', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('input'); + input.type = 'text'; + input.className = 'js-choices'; + input.value = 'Value 1, Value 2, Value 3, Value 4'; + + document.body.appendChild(input); + instance = new Choices(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should handle clearInput()', () => { + instance.clearInput(); + expect(instance.input.value).to.equal(''); + }); + + it('should handle removeItemsByValue()', () => { + const items = instance.currentState.items; + const randomItem = items[Math.floor(Math.random() * items.length)]; + + instance.removeItemsByValue(randomItem.value); + expect(randomItem.active).to.be.false; + }); + }); + + describe('should react to config options', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.setAttribute('multiple', ''); + + for (let i = 1; i < 4; i++) { + const option = document.createElement('option'); + + option.value = `Value ${i}`; + option.innerHTML = `Value ${i}`; + + if (i % 2) { + option.selected = true; + } + + input.appendChild(option); + } + + document.body.appendChild(input); + }); + + afterEach(() => { + instance.destroy(); + }); + + it('should flip the dropdown', () => { + instance = new Choices(input, { + position: 'top', + }); + + const container = instance.containerOuter; + instance.input.focus(); + expect(container.classList.contains(instance.config.classNames.flippedState)).to.be.true; + }); + + it('shouldn\'t flip the dropdown', () => { + instance = new Choices(input, { + position: 'bottom' + }); + + const container = instance.containerOuter; + instance.input.focus(); + expect(container.classList.contains(instance.config.classNames.flippedState)).to.be.false; + }); + + it('should render selected choices', () => { + instance = new Choices(input, { + renderSelectedChoices: 'always', + renderChoiceLimit: -1 + }); + + const renderedChoices = instance.choiceList.querySelectorAll('.choices__item'); + expect(renderedChoices.length).to.equal(3); + }); + + it('shouldn\'t render selected choices', () => { + instance = new Choices(input, { + renderSelectedChoices: 'auto', + renderChoiceLimit: -1 + }); + + const renderedChoices = instance.choiceList.querySelectorAll('.choices__item'); + expect(renderedChoices.length).to.equal(1); + }); + + it('shouldn\'t render choices up to a render limit', () => { + // Remove existing choices (to make test simpler) + while (input.firstChild) { + input.removeChild(input.firstChild); + } + + instance = new Choices(input, { + choices: [ + { + value: 'Option 1', + selected: false, + }, + { + value: 'Option 2', + selected: false, + }, + { + value: 'Option 3', + selected: false, + }, + { + value: 'Option 4', + selected: false, + }, + { + value: 'Option 5', + selected: false, + }, + ], + renderSelectedChoices: 'auto', + renderChoiceLimit: 4 + }); + + const renderedChoices = instance.choiceList.querySelectorAll('.choices__item'); + expect(renderedChoices.length).to.equal(4); + }); + }); + + describe('should allow custom properties provided by the user on items or choices', () => { + it('should allow the user to supply custom properties for an item', () => { + const randomItem = { + id: 8999, + choiceId: 9000, + groupId: 9001, + value: 'value', + label: 'label', + customProperties: { + foo: 'bar' + }, + keyCode: null + }; + + const expectedState = [{ + id: randomItem.id, + choiceId: randomItem.choiceId, + groupId: randomItem.groupId, + value: randomItem.value, + label: randomItem.label, + active: true, + highlighted: false, + customProperties: randomItem.customProperties, + keyCode: randomItem.keyCode + }]; + + const action = addItemAction( + randomItem.value, + randomItem.label, + randomItem.id, + randomItem.choiceId, + randomItem.groupId, + randomItem.customProperties, + randomItem.keyCode + ); + + expect(itemReducer([], action)).to.deep.equal(expectedState); + }); + + it('should allow the user to supply custom properties for a choice', () => { + const randomChoice = { + id: 123, + elementId: 321, + groupId: 213, + value: 'value', + label: 'label', + disabled: false, + customProperties: { + foo: 'bar' + }, + keyCode: null + }; + + const expectedState = [{ + id: randomChoice.id, + elementId: randomChoice.elementId, + groupId: randomChoice.groupId, + value: randomChoice.value, + label: randomChoice.label, + disabled: randomChoice.disabled, + selected: false, + active: true, + score: 9999, + customProperties: randomChoice.customProperties, + keyCode: randomChoice.keyCode + }]; + + const action = addChoiceAction( + randomChoice.value, + randomChoice.label, + randomChoice.id, + randomChoice.groupId, + randomChoice.disabled, + randomChoice.elementId, + randomChoice.customProperties, + randomChoice.keyCode + ); + + expect(choiceReducer([], action)).to.deep.equal(expectedState); + }); + }); + + describe('should allow custom properties provided by the user on items or choices', () => { + let input; + let instance; + + beforeEach(() => { + input = document.createElement('select'); + input.className = 'js-choices'; + input.setAttribute('multiple', ''); + + document.body.appendChild(input); + }); + + afterEach(() => { + 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', () => { + const expectedCustomProperties = { + isBestOptionEver: true + }; + + instance = new Choices(input); + instance.setChoices([{ + value: '42', + label: 'My awesome choice', + selected: false, + disabled: false, + customProperties: expectedCustomProperties + }], 'value', 'label', true); + + instance.setValueByChoice('42'); + const selectedItems = instance.getValue(); + + expect(selectedItems.length).to.equal(1); + expect(selectedItems[0].customProperties).to.equal(expectedCustomProperties); + }); + + it('should allow the user to supply custom properties when directly creating a selected item', () => { + const expectedCustomProperties = { + isBestOptionEver: true + }; + + instance = new Choices(input); + + instance.setValue([{ + value: 'bar', + label: 'foo', + customProperties: expectedCustomProperties + }]); + const selectedItems = instance.getValue(); + + expect(selectedItems.length).to.equal(1); + expect(selectedItems[0].customProperties).to.equal(expectedCustomProperties); + }); + }); +}); diff --git a/assets/scripts/src/components/container.js b/src/scripts/src/components/container.js similarity index 100% rename from assets/scripts/src/components/container.js rename to src/scripts/src/components/container.js diff --git a/assets/scripts/src/components/dropdown.js b/src/scripts/src/components/dropdown.js similarity index 100% rename from assets/scripts/src/components/dropdown.js rename to src/scripts/src/components/dropdown.js diff --git a/assets/scripts/src/components/input.js b/src/scripts/src/components/input.js similarity index 100% rename from assets/scripts/src/components/input.js rename to src/scripts/src/components/input.js diff --git a/assets/scripts/src/components/list.js b/src/scripts/src/components/list.js similarity index 100% rename from assets/scripts/src/components/list.js rename to src/scripts/src/components/list.js diff --git a/assets/scripts/src/lib/polyfills.js b/src/scripts/src/lib/polyfills.js similarity index 100% rename from assets/scripts/src/lib/polyfills.js rename to src/scripts/src/lib/polyfills.js diff --git a/assets/scripts/src/lib/utils.js b/src/scripts/src/lib/utils.js similarity index 100% rename from assets/scripts/src/lib/utils.js rename to src/scripts/src/lib/utils.js diff --git a/assets/scripts/src/reducers/choices.js b/src/scripts/src/reducers/choices.js similarity index 100% rename from assets/scripts/src/reducers/choices.js rename to src/scripts/src/reducers/choices.js diff --git a/assets/scripts/src/reducers/groups.js b/src/scripts/src/reducers/groups.js similarity index 100% rename from assets/scripts/src/reducers/groups.js rename to src/scripts/src/reducers/groups.js diff --git a/assets/scripts/src/reducers/index.js b/src/scripts/src/reducers/index.js similarity index 100% rename from assets/scripts/src/reducers/index.js rename to src/scripts/src/reducers/index.js diff --git a/assets/scripts/src/reducers/items.js b/src/scripts/src/reducers/items.js similarity index 100% rename from assets/scripts/src/reducers/items.js rename to src/scripts/src/reducers/items.js diff --git a/assets/scripts/src/store/index.js b/src/scripts/src/store/store.js similarity index 100% rename from assets/scripts/src/store/index.js rename to src/scripts/src/store/store.js diff --git a/src/scripts/src/store/store.spec.js b/src/scripts/src/store/store.spec.js new file mode 100644 index 0000000..e69de29 diff --git a/assets/styles/css/base.css b/src/styles/css/base.css similarity index 100% rename from assets/styles/css/base.css rename to src/styles/css/base.css diff --git a/assets/styles/css/base.min.css b/src/styles/css/base.min.css similarity index 100% rename from assets/styles/css/base.min.css rename to src/styles/css/base.min.css diff --git a/assets/styles/css/choices.css b/src/styles/css/choices.css similarity index 100% rename from assets/styles/css/choices.css rename to src/styles/css/choices.css diff --git a/assets/styles/css/choices.min.css b/src/styles/css/choices.min.css similarity index 100% rename from assets/styles/css/choices.min.css rename to src/styles/css/choices.min.css diff --git a/assets/styles/scss/base.scss b/src/styles/scss/base.scss similarity index 100% rename from assets/styles/scss/base.scss rename to src/styles/scss/base.scss diff --git a/assets/styles/scss/choices.scss b/src/styles/scss/choices.scss similarity index 100% rename from assets/styles/scss/choices.scss rename to src/styles/scss/choices.scss diff --git a/tests/spec/choices_spec.js b/tests/spec/choices_spec.js index 97bd3e8..fa8ba86 100644 --- a/tests/spec/choices_spec.js +++ b/tests/spec/choices_spec.js @@ -2,13 +2,13 @@ import 'whatwg-fetch'; import 'es6-promise'; import 'core-js/fn/object/assign'; import 'core-js/fn/array/includes'; -import Choices from '../../assets/scripts/src/choices'; -import itemReducer from '../../assets/scripts/src/reducers/items'; -import choiceReducer from '../../assets/scripts/src/reducers/choices'; +import Choices from '../../src/scripts/src/choices.js'; +import itemReducer from '../../src/scripts/src/reducers/items.js'; +import choiceReducer from '../../src/scripts/src/reducers/choices.js'; import { addItem as addItemAction, addChoice as addChoiceAction, -} from '../../assets/scripts/src/actions/index'; +} from '../../assets/scripts/src/actions/actions'; describe('Choices', () => { describe('should initialize Choices', () => { diff --git a/webpack.config.dev.js b/webpack.config.dev.js index 3d5edd3..f8029de 100644 --- a/webpack.config.dev.js +++ b/webpack.config.dev.js @@ -8,12 +8,12 @@ module.exports = { devtool: 'eval', entry: [ 'webpack-dev-server/client?http://localhost:3000', - './assets/scripts/src/choices' + './src/scripts/src/choices' ], output: { path: path.join(__dirname, 'dist'), filename: 'choices.min.js', - publicPath: '/assets/scripts/dist/', + publicPath: '/src/scripts/dist/', library: 'Choices', libraryTarget: 'umd', }, @@ -34,7 +34,7 @@ module.exports = { test: /\.js$/, exclude: /(node_modules|bower_components)/, loaders: ['babel', 'eslint-loader'], - include: path.join(__dirname, 'assets/scripts/src') + include: path.join(__dirname, 'src/scripts/src') }] } }; diff --git a/webpack.config.prod.js b/webpack.config.prod.js index 0931719..f05b4b6 100644 --- a/webpack.config.prod.js +++ b/webpack.config.prod.js @@ -8,12 +8,12 @@ const minimize = process.argv.includes('--minimize'); const config = { devtool: minimize ? false : 'cheap-module-source-map', entry: [ - './assets/scripts/src/choices' + './src/scripts/src/choices' ], output: { - path: path.join(__dirname, '/assets/scripts/dist'), + path: path.join(__dirname, '/src/scripts/dist'), filename: minimize ? 'choices.min.js' : 'choices.js', - publicPath: '/assets/scripts/dist/', + publicPath: '/src/scripts/dist/', library: 'Choices', libraryTarget: 'umd', }, @@ -34,7 +34,7 @@ const config = { test: /\.js$/, exclude: /(node_modules|bower_components)/, loaders: ['babel'], - include: path.join(__dirname, 'assets/scripts/src') + include: path.join(__dirname, 'src/scripts/src') }] } };