Fix constructor (#693)

* breaking test

* Remove ablity to pass multiple elements + tests

* Update readme

* Update README.md

* 🔖 Version 8.0.0

* Remove type definition hack

* Update coverage command

* Add some missing list tests

* Remove .only

* Update demo page to loop over elements

* Update constructor to set initialised flag if already active

* Make templates private

* Throw type error once if element is invalid

* Fix list children bug

* Re-add generic examples to index.html

* Housekeeping

* Use typeof instead of isType where applicable

* Remove isElement

* Add test for isIE11
This commit is contained in:
Josh Johnson 2019-10-29 21:19:56 +00:00 committed by GitHub
parent 88f63faa0b
commit 0e44a916e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 842 additions and 544 deletions

View file

@ -64,7 +64,8 @@
}, },
"rules": { "rules": {
"no-restricted-syntax": "off", "no-restricted-syntax": "off",
"compat/compat": "off" "compat/compat": "off",
"no-new": "off"
} }
}, },
{ {

View file

@ -20,7 +20,7 @@ jobs:
npm ci npm ci
npm run build npm run build
npx bundlesize npx bundlesize
npm run coverage npm run test:unit:coverage
npm run test:e2e npm run test:e2e
env: env:
CI: true CI: true
@ -85,4 +85,4 @@ jobs:
env: env:
ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }} ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }}
PUBLISH_BRANCH: gh-pages PUBLISH_BRANCH: gh-pages
PUBLISH_DIR: ./public PUBLISH_DIR: ./public

View file

@ -25,7 +25,7 @@ jobs:
CYPRESS_INSTALL_BINARY: 0 CYPRESS_INSTALL_BINARY: 0
HUSKY_SKIP_INSTALL: true HUSKY_SKIP_INSTALL: true
- run: npm run coverage - run: npm run test:unit:coverage
env: env:
FORCE_COLOR: 2 FORCE_COLOR: 2

View file

@ -67,14 +67,11 @@ Or include Choices directly:
## Setup ## Setup
If you pass a selector which targets multiple elements, an array of Choices instances **Note:** If you pass a selector which targets multiple elements, the first matching element will be used. Versions prior to 8.x.x would return multiple Choices instances.
will be returned. If you target one element, that instance will be returned.
```js ```js
// Pass multiple elements: // Pass single element
const [firstInstance, secondInstance] = new Choices(elements); const element = document.querySelector('.js-choice');
// Pass single element:
const choices = new Choices(element); const choices = new Choices(element);
// Pass reference // Pass reference
@ -84,8 +81,8 @@ will be returned. If you target one element, that instance will be returned.
// Pass jQuery element // Pass jQuery element
const choices = new Choices($('.js-choice')[0]); const choices = new Choices($('.js-choice')[0]);
// Passing options (with default options) // Passing options (with default options)
const choices = new Choices(elements, { const choices = new Choices(element, {
silent: false, silent: false,
items: [], items: [],
choices: [], choices: [],

View file

@ -1,3 +1,5 @@
/* eslint-disable no-param-reassign */
const { JSDOM } = require('jsdom'); const { JSDOM } = require('jsdom');
const jsdom = new JSDOM( const jsdom = new JSDOM(
@ -36,6 +38,8 @@ global.HTMLElement = window.HTMLElement;
global.Option = window.Option; global.Option = window.Option;
global.HTMLOptionElement = window.HTMLOptionElement; global.HTMLOptionElement = window.HTMLOptionElement;
global.HTMLOptGroupElement = window.HTMLOptGroupElement; global.HTMLOptGroupElement = window.HTMLOptGroupElement;
global.HTMLSelectElement = window.HTMLSelectElement;
global.HTMLInputElement = window.HTMLInputElement;
global.DocumentFragment = window.DocumentFragment; global.DocumentFragment = window.DocumentFragment;
global.requestAnimationFrame = window.requestAnimationFrame; global.requestAnimationFrame = window.requestAnimationFrame;

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "choices.js", "name": "choices.js",
"version": "7.1.5", "version": "8.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View file

@ -1,6 +1,6 @@
{ {
"name": "choices.js", "name": "choices.js",
"version": "7.1.5", "version": "8.0.0",
"description": "A vanilla JS customisable text input/select box plugin", "description": "A vanilla JS customisable text input/select box plugin",
"main": "./public/assets/scripts/choices.js", "main": "./public/assets/scripts/choices.js",
"types": "./types/index.d.ts", "types": "./types/index.d.ts",
@ -8,7 +8,6 @@
"start": "run-p js:watch css:watch", "start": "run-p js:watch css:watch",
"build": "run-p js:build css:build", "build": "run-p js:build css:build",
"lint": "eslint src/scripts", "lint": "eslint src/scripts",
"coverage": "NODE_ENV=test nyc --reporter=lcov --reporter=text --reporter=text-summary mocha",
"bundlesize": "bundlesize", "bundlesize": "bundlesize",
"cypress:run": "$(npm bin)/cypress run", "cypress:run": "$(npm bin)/cypress run",
"cypress:open": "$(npm bin)/cypress open", "cypress:open": "$(npm bin)/cypress open",
@ -16,6 +15,7 @@
"test": "run-s test:unit test:e2e", "test": "run-s test:unit test:e2e",
"test:unit": "NODE_ENV=test mocha", "test:unit": "NODE_ENV=test mocha",
"test:unit:watch": "NODE_ENV=test mocha --watch --inspect=5556", "test:unit:watch": "NODE_ENV=test mocha --watch --inspect=5556",
"test:unit:coverage": "NODE_ENV=test nyc --reporter=lcov --reporter=text --reporter=text-summary mocha",
"test:e2e": "run-p --race start cypress:run", "test:e2e": "run-p --race start cypress:run",
"js:watch": "NODE_ENV=development node server.js", "js:watch": "NODE_ENV=development node server.js",
"js:build": "webpack --config webpack.config.prod.js", "js:build": "webpack --config webpack.config.prod.js",

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -78,7 +78,8 @@ a:focus {
border-radius: 2.5px; border-radius: 2.5px;
font-size: 14px; font-size: 14px;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; -moz-appearance: none;
appearance: none;
margin-bottom: 24px; margin-bottom: 24px;
} }

View file

@ -1 +1 @@
*{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:after,:before{box-sizing:border-box}body,html{position:relative;margin:0;width:100%;height:100%}body{font-family:'Helvetica Neue',Helvetica,Arial,'Lucida Grande',sans-serif;font-size:16px;line-height:1.4;color:#fff;background-color:#333;overflow-x:hidden}hr,label{display:block}label,p{margin-bottom:8px}label{font-size:14px;font-weight:500;cursor:pointer}p{margin-top:0}hr{margin:30px 0;border:0;border-bottom:1px solid #eaeaea;height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:12px;font-weight:400;line-height:1.2}a,a:focus,a:visited{color:#fff;text-decoration:none;font-weight:600}.form-control{display:block;width:100%;background-color:#f9f9f9;padding:12px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;-webkit-appearance:none;appearance:none;margin-bottom:24px}.h1,h1{font-size:32px}.h2,h2{font-size:24px}.h3,h3{font-size:20px}.h4,h4{font-size:18px}.h5,h5{font-size:16px}.h6,h6{font-size:14px}label+p{margin-top:-4px}.container{display:block;margin:auto;max-width:40em;padding:48px}@media (max-width:620px){.container{padding:0}}.section{background-color:#fff;padding:24px;color:#333}.section a,.section a:focus,.section a:visited{color:#00bcd4}.logo{display:block;margin-bottom:12px}.logo__img{width:100%;height:auto;display:inline-block;max-width:100%;vertical-align:top;padding:6px 0}.visible-ie{display:none}.push-bottom{margin-bottom:24px}.zero-bottom{margin-bottom:0}.zero-top{margin-top:0}.text-center{text-align:center}[data-test-hook]{margin-bottom:24px} *{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:after,:before{box-sizing:border-box}body,html{position:relative;margin:0;width:100%;height:100%}body{font-family:'Helvetica Neue',Helvetica,Arial,'Lucida Grande',sans-serif;font-size:16px;line-height:1.4;color:#fff;background-color:#333;overflow-x:hidden}hr,label{display:block}label,p{margin-bottom:8px}label{font-size:14px;font-weight:500;cursor:pointer}p{margin-top:0}hr{margin:30px 0;border:0;border-bottom:1px solid #eaeaea;height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:12px;font-weight:400;line-height:1.2}a,a:focus,a:visited{color:#fff;text-decoration:none;font-weight:600}.form-control{display:block;width:100%;background-color:#f9f9f9;padding:12px;border:1px solid #ddd;border-radius:2.5px;font-size:14px;-webkit-appearance:none;-moz-appearance:none;appearance:none;margin-bottom:24px}.h1,h1{font-size:32px}.h2,h2{font-size:24px}.h3,h3{font-size:20px}.h4,h4{font-size:18px}.h5,h5{font-size:16px}.h6,h6{font-size:14px}label+p{margin-top:-4px}.container{display:block;margin:auto;max-width:40em;padding:48px}@media (max-width:620px){.container{padding:0}}.section{background-color:#fff;padding:24px;color:#333}.section a,.section a:focus,.section a:visited{color:#00bcd4}.logo{display:block;margin-bottom:12px}.logo__img{width:100%;height:auto;display:inline-block;max-width:100%;vertical-align:top;padding:6px 0}.visible-ie{display:none}.push-bottom{margin-bottom:24px}.zero-bottom{margin-bottom:0}.zero-top{margin-top:0}.text-center{text-align:center}[data-test-hook]{margin-bottom:24px}

View file

@ -325,7 +325,8 @@
.choices__button { .choices__button {
text-indent: -9999px; text-indent: -9999px;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; -moz-appearance: none;
appearance: none;
border: 0; border: 0;
background-color: transparent; background-color: transparent;
background-repeat: no-repeat; background-repeat: no-repeat;

File diff suppressed because one or more lines are too long

View file

@ -548,6 +548,15 @@
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var genericExamples = document.querySelectorAll('[data-trigger]');
for (i = 0; i < genericExamples.length; ++i) {
var element = genericExamples[i];
new Choices(element, {
placeholderValue: 'This is a placeholder set in the config',
searchPlaceholderValue: 'This is a search placeholder',
});
}
var textRemove = new Choices( var textRemove = new Choices(
document.getElementById('choices-text-remove-button'), document.getElementById('choices-text-remove-button'),
{ {

View file

@ -48,10 +48,6 @@ const USER_DEFAULTS = /** @type {Partial<import('../../types/index').Choices.Opt
* @typedef {import('../../types/index').Choices.Choice} Choice * @typedef {import('../../types/index').Choices.Choice} Choice
*/ */
class Choices { class Choices {
/* ========================================
= Static properties =
======================================== */
static get defaults() { static get defaults() {
return Object.preventExtensions({ return Object.preventExtensions({
get options() { get options() {
@ -63,17 +59,11 @@ class Choices {
}); });
} }
/**
* @param {string | HTMLInputElement | HTMLSelectElement} element
* @param {Partial<import('../../types/index').Choices.Options>} userConfig
*/
constructor(element = '[data-choice]', userConfig = {}) { constructor(element = '[data-choice]', userConfig = {}) {
if (isType('String', element)) {
const elements = Array.from(document.querySelectorAll(element));
// If there are multiple elements, create a new instance
// for each element besides the first one (as that already has an instance)
if (elements.length > 1) {
return this._generateInstances(elements, userConfig);
}
}
this.config = merge.all( this.config = merge.all(
[DEFAULT_CONFIG, Choices.defaults.options, userConfig], [DEFAULT_CONFIG, Choices.defaults.options, userConfig],
// When merging array configs, replace with a copy of the userConfig array, // When merging array configs, replace with a copy of the userConfig array,
@ -90,6 +80,7 @@ class Choices {
userConfig.addItemFilter instanceof RegExp userConfig.addItemFilter instanceof RegExp
? userConfig.addItemFilter ? userConfig.addItemFilter
: new RegExp(userConfig.addItemFilter); : new RegExp(userConfig.addItemFilter);
this.config.addItemFilter = re.test.bind(re); this.config.addItemFilter = re.test.bind(re);
} }
@ -105,19 +96,18 @@ class Choices {
this.config.renderSelectedChoices = 'auto'; this.config.renderSelectedChoices = 'auto';
} }
// Retrieve triggering element (i.e. element with 'data-choice' trigger) const passedElement =
const passedElement = isType('String', element) typeof element === 'string' ? document.querySelector(element) : element;
? document.querySelector(element)
: element;
if (!passedElement) { if (
if (!this.config.silent) { !(
console.error( passedElement instanceof HTMLInputElement ||
'Could not find passed element or passed element was of an invalid type', passedElement instanceof HTMLSelectElement
); )
} ) {
throw TypeError(
return; 'Expected one of the following types text|select-one|select-multiple',
);
} }
this._isTextElement = passedElement.type === 'text'; this._isTextElement = passedElement.type === 'text';
@ -132,21 +122,17 @@ class Choices {
classNames: this.config.classNames, classNames: this.config.classNames,
delimiter: this.config.delimiter, delimiter: this.config.delimiter,
}); });
} else if (this._isSelectElement) { } else {
this.passedElement = new WrappedSelect({ this.passedElement = new WrappedSelect({
element: passedElement, element: passedElement,
classNames: this.config.classNames, classNames: this.config.classNames,
template: data => this.config.templates.option(data), template: data => this._templates.option(data),
}); });
} }
if (!this.passedElement) {
return console.error('Passed element was of an invalid type');
}
this.initialised = false; this.initialised = false;
this._store = new Store(this.render); this._store = new Store();
this._initialState = {}; this._initialState = {};
this._currentState = {}; this._currentState = {};
this._prevState = {}; this._prevState = {};
@ -205,29 +191,31 @@ class Choices {
this._onDirectionKey = this._onDirectionKey.bind(this); this._onDirectionKey = this._onDirectionKey.bind(this);
this._onDeleteKey = this._onDeleteKey.bind(this); this._onDeleteKey = this._onDeleteKey.bind(this);
if (!this.config.silent) { if (this.config.shouldSortItems === true && this._isSelectOneElement) {
if (this.config.shouldSortItems === true && this._isSelectOneElement) { if (!this.config.silent) {
console.warn( console.warn(
"shouldSortElements: Type of passed element is 'select-one', falling back to false.", "shouldSortElements: Type of passed element is 'select-one', falling back to false.",
); );
} }
}
// If element has already been initialised with Choices, fail silently // If element has already been initialised with Choices, fail silently
if (this.passedElement.element.getAttribute('data-choice') === 'active') { if (this.passedElement.element.getAttribute('data-choice') === 'active') {
if (!this.config.silent) {
console.warn( console.warn(
'Trying to initialise Choices on element already initialised', 'Trying to initialise Choices on element already initialised',
); );
} }
this.initialised = true;
return;
} }
// Let's go // Let's go
this.init(); this.init();
} }
/* ========================================
= Public methods =
======================================== */
init() { init() {
if (this.initialised) { if (this.initialised) {
return; return;
@ -256,7 +244,7 @@ class Choices {
const { callbackOnInit } = this.config; const { callbackOnInit } = this.config;
// Run callback if it is a function // Run callback if it is a function
if (callbackOnInit && isType('Function', callbackOnInit)) { if (callbackOnInit && typeof callbackOnInit === 'function') {
callbackOnInit.call(this); callbackOnInit.call(this);
} }
} }
@ -275,8 +263,7 @@ class Choices {
} }
this.clearStore(); this.clearStore();
this._templates = null;
this.config.templates = null;
this.initialised = false; this.initialised = false;
} }
@ -459,7 +446,7 @@ class Choices {
} }
// If only one value has been passed, convert to array // If only one value has been passed, convert to array
const choiceValue = isType('Array', value) ? value : [value]; const choiceValue = Array.isArray(value) ? value : [value];
// Loop through each value and // Loop through each value and
choiceValue.forEach(val => this._findAndSelectChoiceByValue(val)); choiceValue.forEach(val => this._findAndSelectChoiceByValue(val));
@ -649,12 +636,6 @@ class Choices {
return this; return this;
} }
/* ===== End of Public methods ====== */
/* =============================================
= Private functions =
============================================= */
_render() { _render() {
if (this._store.isLoading()) { if (this._store.isLoading()) {
return; return;
@ -743,15 +724,17 @@ class Choices {
let notice; let notice;
if (this._isSearching) { if (this._isSearching) {
notice = isType('Function', this.config.noResultsText) notice =
? this.config.noResultsText() typeof this.config.noResultsText === 'function'
: this.config.noResultsText; ? this.config.noResultsText()
: this.config.noResultsText;
dropdownItem = this._getTemplate('notice', notice, 'no-results'); dropdownItem = this._getTemplate('notice', notice, 'no-results');
} else { } else {
notice = isType('Function', this.config.noChoicesText) notice =
? this.config.noChoicesText() typeof this.config.noChoicesText === 'function'
: this.config.noChoicesText; ? this.config.noChoicesText()
: this.config.noChoicesText;
dropdownItem = this._getTemplate('notice', notice, 'no-choices'); dropdownItem = this._getTemplate('notice', notice, 'no-choices');
} }
@ -1126,9 +1109,10 @@ class Choices {
_canAddItem(activeItems, value) { _canAddItem(activeItems, value) {
let canAddItem = true; let canAddItem = true;
let notice = isType('Function', this.config.addItemText) let notice =
? this.config.addItemText(value) typeof this.config.addItemText === 'function'
: this.config.addItemText; ? this.config.addItemText(value)
: this.config.addItemText;
if (!this._isSelectOneElement) { if (!this._isSelectOneElement) {
const isDuplicateValue = existsInArray(activeItems, value); const isDuplicateValue = existsInArray(activeItems, value);
@ -1140,9 +1124,10 @@ class Choices {
// If there is a max entry limit and we have reached that limit // If there is a max entry limit and we have reached that limit
// don't update // don't update
canAddItem = false; canAddItem = false;
notice = isType('Function', this.config.maxItemText) notice =
? this.config.maxItemText(this.config.maxItemCount) typeof this.config.maxItemText === 'function'
: this.config.maxItemText; ? this.config.maxItemText(this.config.maxItemCount)
: this.config.maxItemText;
} }
if ( if (
@ -1151,9 +1136,10 @@ class Choices {
canAddItem canAddItem
) { ) {
canAddItem = false; canAddItem = false;
notice = isType('Function', this.config.uniqueItemText) notice =
? this.config.uniqueItemText(value) typeof this.config.uniqueItemText === 'function'
: this.config.uniqueItemText; ? this.config.uniqueItemText(value)
: this.config.uniqueItemText;
} }
if ( if (
@ -1178,10 +1164,11 @@ class Choices {
} }
_searchChoices(value) { _searchChoices(value) {
const newValue = isType('String', value) ? value.trim() : value; const newValue = typeof value === 'string' ? value.trim() : value;
const currentValue = isType('String', this._currentValue) const currentValue =
? this._currentValue.trim() typeof this._currentValue === 'string'
: this._currentValue; ? this._currentValue.trim()
: this._currentValue;
if (newValue.length < 1 && newValue === `${currentValue} `) { if (newValue.length < 1 && newValue === `${currentValue} `) {
return 0; return 0;
@ -1307,7 +1294,7 @@ class Choices {
const { activeItems } = this._store; const { activeItems } = this._store;
const hasFocusedInput = this.input.isFocussed; const hasFocusedInput = this.input.isFocussed;
const hasActiveDropdown = this.dropdown.isActive; const hasActiveDropdown = this.dropdown.isActive;
const hasItems = this.itemList.hasChildren; const hasItems = this.itemList.hasChildren();
const keyString = String.fromCharCode(keyCode); const keyString = String.fromCharCode(keyCode);
const { const {
@ -1560,7 +1547,10 @@ class Choices {
_onMouseDown(event) { _onMouseDown(event) {
const { target, shiftKey } = event; const { target, shiftKey } = event;
// If we have our mouse down on the scrollbar and are on IE11... // If we have our mouse down on the scrollbar and are on IE11...
if (this.choiceList.element.contains(target) && isIE11()) { if (
this.choiceList.element.contains(target) &&
isIE11(navigator.userAgent)
) {
this._isScrollingOnIe = true; this._isScrollingOnIe = true;
} }
@ -1777,7 +1767,7 @@ class Choices {
placeholder = false, placeholder = false,
keyCode = null, keyCode = null,
}) { }) {
let passedValue = isType('String', value) ? value.trim() : value; let passedValue = typeof value === 'string' ? value.trim() : value;
const passedKeyCode = keyCode; const passedKeyCode = keyCode;
const passedCustomProperties = customProperties; const passedCustomProperties = customProperties;
@ -1939,9 +1929,9 @@ class Choices {
return null; return null;
} }
const { templates, classNames } = this.config; const { classNames } = this.config;
return templates[template].call(this, classNames, ...args); return this._templates[template].call(this, classNames, ...args);
} }
_createTemplates() { _createTemplates() {
@ -1950,12 +1940,12 @@ class Choices {
if ( if (
callbackOnCreateTemplates && callbackOnCreateTemplates &&
isType('Function', callbackOnCreateTemplates) typeof callbackOnCreateTemplates === 'function'
) { ) {
userTemplates = callbackOnCreateTemplates.call(this, strToEl); userTemplates = callbackOnCreateTemplates.call(this, strToEl);
} }
this.config.templates = merge(TEMPLATES, userTemplates); this._templates = merge(TEMPLATES, userTemplates);
} }
_createElements() { _createElements() {
@ -2230,17 +2220,6 @@ class Choices {
} }
} }
_generateInstances(elements, config) {
return elements.reduce(
(instances, element) => {
instances.push(new Choices(element, config));
return instances;
},
[this],
);
}
_generatePlaceholderValue() { _generatePlaceholderValue() {
if (this._isSelectOneElement) { if (this._isSelectOneElement) {
return false; return false;

View file

@ -2,19 +2,14 @@ import { expect } from 'chai';
import { spy, stub } from 'sinon'; import { spy, stub } from 'sinon';
import Choices from './choices'; import Choices from './choices';
import { EVENTS, ACTION_TYPES } from './constants'; import { EVENTS, ACTION_TYPES, DEFAULT_CONFIG } from './constants';
import { WrappedSelect, WrappedInput } from './components/index';
describe('choices', () => { describe('choices', () => {
let instance; let instance;
let output; let output;
let passedElement; let passedElement;
const returnsInstance = () => {
it('returns this', () => {
expect(output).to.eql(instance);
});
};
beforeEach(() => { beforeEach(() => {
passedElement = document.createElement('input'); passedElement = document.createElement('input');
passedElement.type = 'text'; passedElement.type = 'text';
@ -24,12 +19,198 @@ describe('choices', () => {
instance = new Choices(passedElement); instance = new Choices(passedElement);
}); });
describe('public methods', () => { afterEach(() => {
afterEach(() => { output = null;
output = null; instance = null;
instance = null; });
const returnsInstance = () => {
it('returns this', () => {
expect(output).to.eql(instance);
});
};
describe('constructor', () => {
describe('config', () => {
describe('not passing config options', () => {
it('uses the default config', () => {
document.body.innerHTML = `
<input data-choice type="text" id="input-1" />
`;
instance = new Choices();
expect(instance.config).to.eql(DEFAULT_CONFIG);
});
});
describe('passing config options', () => {
it('merges the passed config with the default config', () => {
document.body.innerHTML = `
<input data-choice type="text" id="input-1" />
`;
const config = {
renderChoiceLimit: 5,
};
instance = new Choices('[data-choice]', config);
expect(instance.config).to.eql({
...DEFAULT_CONFIG,
...config,
});
});
});
}); });
describe('not passing an element', () => {
it('returns a Choices instance for the first element with a "data-choice" attribute', () => {
document.body.innerHTML = `
<input data-choice type="text" id="input-1" />
<input data-choice type="text" id="input-2" />
<input data-choice type="text" id="input-3" />
`;
const inputs = document.querySelectorAll('[data-choice]');
expect(inputs.length).to.equal(3);
instance = new Choices();
expect(instance.passedElement.element.id).to.equal(inputs[0].id);
});
describe('when an element cannot be found in the DOM', () => {
it('throws an error', () => {
document.body.innerHTML = ``;
expect(() => new Choices()).to.throw(
TypeError,
'Expected one of the following types text|select-one|select-multiple',
);
});
});
});
describe('passing an element', () => {
describe('passing an element that has not been initialised with Choices', () => {
beforeEach(() => {
document.body.innerHTML = `
<input type="text" id="input-1" />
`;
});
it('sets the initialised flag to true', () => {
instance = new Choices('#input-1');
expect(instance.initialised).to.equal(true);
});
it('intialises', () => {
const initSpy = spy();
// initialise with the same element
instance = new Choices('#input-1', {
silent: true,
callbackOnInit: initSpy,
});
expect(initSpy.called).to.equal(true);
});
});
describe('passing an element that has already be initialised with Choices', () => {
beforeEach(() => {
document.body.innerHTML = `
<input type="text" id="input-1" />
`;
// initialise once
new Choices('#input-1', { silent: true });
});
it('sets the initialised flag to true', () => {
// initialise with the same element
instance = new Choices('#input-1', { silent: true });
expect(instance.initialised).to.equal(true);
});
it('does not reinitialise', () => {
const initSpy = spy();
// initialise with the same element
instance = new Choices('#input-1', {
silent: true,
callbackOnInit: initSpy,
});
expect(initSpy.called).to.equal(false);
});
});
describe(`passing an element as a DOMString`, () => {
describe('passing a input element type', () => {
it('sets the "passedElement" instance property as an instance of WrappedInput', () => {
document.body.innerHTML = `
<input data-choice type="text" id="input-1" />
`;
instance = new Choices('[data-choice]');
expect(instance.passedElement).to.be.an.instanceOf(WrappedInput);
});
});
describe('passing a select element type', () => {
it('sets the "passedElement" instance property as an instance of WrappedSelect', () => {
document.body.innerHTML = `
<select data-choice id="select-1"></select>
`;
instance = new Choices('[data-choice]');
expect(instance.passedElement).to.be.an.instanceOf(WrappedSelect);
});
});
});
describe(`passing an element as a HTMLElement`, () => {
describe('passing a input element type', () => {
it('sets the "passedElement" instance property as an instance of WrappedInput', () => {
document.body.innerHTML = `
<input data-choice type="text" id="input-1" />
`;
instance = new Choices(document.querySelector('[data-choice]'));
expect(instance.passedElement).to.be.an.instanceOf(WrappedInput);
});
});
describe('passing a select element type', () => {
it('sets the "passedElement" instance property as an instance of WrappedSelect', () => {
document.body.innerHTML = `
<select data-choice id="select-1"></select>
`;
instance = new Choices(document.querySelector('[data-choice]'));
expect(instance.passedElement).to.be.an.instanceOf(WrappedSelect);
});
});
});
describe('passing an invalid element type', () => {
it('throws an TypeError', () => {
document.body.innerHTML = `
<div data-choice id="div-1"></div>
`;
expect(() => new Choices('[data-choice]')).to.throw(
TypeError,
'Expected one of the following types text|select-one|select-multiple',
);
});
});
});
});
describe('public methods', () => {
describe('init', () => { describe('init', () => {
const callbackOnInitSpy = spy(); const callbackOnInitSpy = spy();
@ -172,7 +353,7 @@ describe('choices', () => {
}); });
it('nullifys templates config', () => { it('nullifys templates config', () => {
expect(instance.config.templates).to.equal(null); expect(instance._templates).to.equal(null);
}); });
it('resets initialise flag', () => { it('resets initialise flag', () => {

View file

@ -6,7 +6,6 @@ export default class List {
this.scrollPos = this.element.scrollTop; this.scrollPos = this.element.scrollTop;
this.height = this.element.offsetHeight; this.height = this.element.offsetHeight;
this.hasChildren = !!this.element.children;
} }
clear() { clear() {
@ -21,6 +20,10 @@ export default class List {
return this.element.querySelector(selector); return this.element.querySelector(selector);
} }
hasChildren() {
return this.element.hasChildNodes();
}
scrollToTop() { scrollToTop() {
this.element.scrollTop = 0; this.element.scrollTop = 0;
} }
@ -37,52 +40,52 @@ export default class List {
// Scroll position of dropdown // Scroll position of dropdown
const containerScrollPos = this.element.scrollTop + dropdownHeight; const containerScrollPos = this.element.scrollTop + dropdownHeight;
// Difference between the choice and scroll position // Difference between the choice and scroll position
const endpoint = const destination =
direction > 0 direction > 0
? this.element.scrollTop + choicePos - containerScrollPos ? this.element.scrollTop + choicePos - containerScrollPos
: choice.offsetTop; : choice.offsetTop;
requestAnimationFrame(time => { requestAnimationFrame(time => {
this._animateScroll(time, endpoint, direction); this._animateScroll(time, destination, direction);
}); });
} }
_scrollDown(scrollPos, strength, endpoint) { _scrollDown(scrollPos, strength, destination) {
const easing = (endpoint - scrollPos) / strength; const easing = (destination - scrollPos) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
this.element.scrollTop = scrollPos + distance; this.element.scrollTop = scrollPos + distance;
} }
_scrollUp(scrollPos, strength, endpoint) { _scrollUp(scrollPos, strength, destination) {
const easing = (scrollPos - endpoint) / strength; const easing = (scrollPos - destination) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
this.element.scrollTop = scrollPos - distance; this.element.scrollTop = scrollPos - distance;
} }
_animateScroll(time, endpoint, direction) { _animateScroll(time, destination, direction) {
const strength = SCROLLING_SPEED; const strength = SCROLLING_SPEED;
const choiceListScrollTop = this.element.scrollTop; const choiceListScrollTop = this.element.scrollTop;
let continueAnimation = false; let continueAnimation = false;
if (direction > 0) { if (direction > 0) {
this._scrollDown(choiceListScrollTop, strength, endpoint); this._scrollDown(choiceListScrollTop, strength, destination);
if (choiceListScrollTop < endpoint) { if (choiceListScrollTop < destination) {
continueAnimation = true; continueAnimation = true;
} }
} else { } else {
this._scrollUp(choiceListScrollTop, strength, endpoint); this._scrollUp(choiceListScrollTop, strength, destination);
if (choiceListScrollTop > endpoint) { if (choiceListScrollTop > destination) {
continueAnimation = true; continueAnimation = true;
} }
} }
if (continueAnimation) { if (continueAnimation) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this._animateScroll(time, endpoint, direction); this._animateScroll(time, destination, direction);
}); });
} }
} }

View file

@ -21,6 +21,10 @@ describe('components/list', () => {
it('assigns choices element to class', () => { it('assigns choices element to class', () => {
expect(instance.element).to.eql(choicesElement); expect(instance.element).to.eql(choicesElement);
}); });
it('sets the height of the element', () => {
expect(instance.height).to.eql(choicesElement.scrollTop);
});
}); });
describe('clear', () => { describe('clear', () => {
@ -62,4 +66,31 @@ describe('components/list', () => {
expect(expectedResponse).to.eql(actualResponse); expect(expectedResponse).to.eql(actualResponse);
}); });
}); });
describe('hasChildren', () => {
describe('when list has children', () => {
it('returns true', () => {
const childElement = document.createElement('span');
instance.element.appendChild(childElement);
const response = instance.hasChildren();
expect(response).to.equal(true);
});
});
describe('when list does not have children', () => {
it('returns false', () => {
instance.element.innerHTML = '';
const response = instance.hasChildren();
expect(response).to.equal(false);
});
});
});
describe('scrollToTop', () => {
it("sets the position's scroll position to 0", () => {
instance.element.scrollTop = 10;
instance.scrollToTop();
expect(instance.element.scrollTop).to.equal(0);
});
});
}); });

View file

@ -1,10 +1,10 @@
import { dispatchEvent, isElement } from '../lib/utils'; import { dispatchEvent } from '../lib/utils';
export default class WrappedElement { export default class WrappedElement {
constructor({ element, classNames }) { constructor({ element, classNames }) {
Object.assign(this, { element, classNames }); Object.assign(this, { element, classNames });
if (!isElement(element)) { if (!(element instanceof Element)) {
throw new TypeError('Invalid element passed'); throw new TypeError('Invalid element passed');
} }

View file

@ -28,8 +28,6 @@ export const getType = obj => Object.prototype.toString.call(obj).slice(8, -1);
export const isType = (type, obj) => export const isType = (type, obj) =>
obj !== undefined && obj !== null && getType(obj) === type; obj !== undefined && obj !== null && getType(obj) === type;
export const isElement = element => element instanceof Element;
export const wrap = (element, wrapper = document.createElement('div')) => { export const wrap = (element, wrapper = document.createElement('div')) => {
if (element.nextSibling) { if (element.nextSibling) {
element.parentNode.insertBefore(wrapper, element.nextSibling); element.parentNode.insertBefore(wrapper, element.nextSibling);
@ -80,7 +78,7 @@ export const isScrolledIntoView = (el, parent, direction = 1) => {
}; };
export const sanitise = value => { export const sanitise = value => {
if (!isType('String', value)) { if (typeof value !== 'string') {
return value; return value;
} }
@ -145,15 +143,12 @@ export const getWindowHeight = () => {
); );
}; };
export const isIE11 = () => export const isIE11 = userAgent =>
!!( !!(userAgent.match(/Trident/) && userAgent.match(/rv[ :]11/));
navigator.userAgent.match(/Trident/) &&
navigator.userAgent.match(/rv[ :]11/)
);
export const existsInArray = (array, value, key = 'value') => export const existsInArray = (array, value, key = 'value') =>
array.some(item => { array.some(item => {
if (isType('String', value)) { if (typeof value === 'string') {
return item[key] === value.trim(); return item[key] === value.trim();
} }

View file

@ -6,10 +6,10 @@ import {
generateId, generateId,
getType, getType,
isType, isType,
isElement,
sanitise, sanitise,
sortByAlpha, sortByAlpha,
sortByScore, sortByScore,
isIE11,
existsInArray, existsInArray,
cloneObject, cloneObject,
dispatchEvent, dispatchEvent,
@ -96,14 +96,6 @@ describe('utils', () => {
}); });
}); });
describe('isElement', () => {
it('checks with given object is an element', () => {
const element = document.createElement('div');
expect(isElement(element)).to.equal(true);
expect(isElement({})).to.equal(false);
});
});
describe('sanitise', () => { describe('sanitise', () => {
it('strips HTML from value', () => { it('strips HTML from value', () => {
const value = '<script>somethingMalicious();</script>'; const value = '<script>somethingMalicious();</script>';
@ -197,6 +189,18 @@ describe('utils', () => {
}); });
}); });
describe('isIE11', () => {
it('returns whether the given user agent string matches an IE11 user agent string', () => {
const IE11UserAgent =
'Mozilla/5.0 (Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko';
const firefoxUserAgent =
'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0';
expect(isIE11(IE11UserAgent)).to.equal(true);
expect(isIE11(firefoxUserAgent)).to.equal(false);
});
});
describe('existsInArray', () => { describe('existsInArray', () => {
it('determines whether a value exists within given array', () => { it('determines whether a value exists within given array', () => {
const values = [ const values = [

10
types/index.d.ts vendored
View file

@ -777,16 +777,6 @@ export default class Choices {
userConfig?: Partial<Choices.Options>, userConfig?: Partial<Choices.Options>,
); );
/**
* It's impossible to declare in TypeScript what Choices constructor is actually doing:
* @see {@link https://github.com/Microsoft/TypeScript/issues/27594}
* it returns array of Choices in case if selectorOrElement is string
* and one instance of Choices otherwise
* This little hack will at least allow to use it in Typescript
*
*/
[index: number]: this;
/** /**
* Creates a new instance of Choices, adds event listeners, creates templates and renders a Choices element to the DOM. * Creates a new instance of Choices, adds event listeners, creates templates and renders a Choices element to the DOM.
* *