Choice 1
Choice 2
Choice 3
@@ -203,7 +301,11 @@
Search by label
-
+
label1
label2
@@ -214,13 +316,17 @@
document.addEventListener('DOMContentLoaded', function() {
const choicesBasic = new Choices('#choices-basic');
- document.querySelector('button.disable').addEventListener('click', () => {
- choicesBasic.disable();
- });
+ document
+ .querySelector('button.disable')
+ .addEventListener('click', () => {
+ choicesBasic.disable();
+ });
- document.querySelector('button.enable').addEventListener('click', () => {
- choicesBasic.enable();
- });
+ document
+ .querySelector('button.enable')
+ .addEventListener('click', () => {
+ choicesBasic.enable();
+ });
new Choices('#choices-remove-button', {
removeItemButton: true,
@@ -242,12 +348,12 @@
});
new Choices('#choices-render-choice-limit', {
- renderChoiceLimit: 1
+ renderChoiceLimit: 1,
});
new Choices('#choices-search-disabled', {
- searchEnabled: false
- })
+ searchEnabled: false,
+ });
new Choices('#choices-search-floor', {
searchFloor: 5,
@@ -255,16 +361,9 @@
new Choices('#choices-remote-data', {
shouldSort: false,
- }).ajax((callback) => {
- fetch('/data')
- .then((response) => {
- response.json().then((data) => {
- callback(data, 'value', 'label');
- });
- })
- .catch((error) => {
- console.error(error);
- });
+ }).setChoices(async () => {
+ const res = await fetch('/data');
+ return res.json();
});
new Choices('#choices-scrolling-dropdown', {
@@ -276,7 +375,7 @@
const parent = new Choices('#choices-parent');
const child = new Choices('#choices-child').disable();
- parent.passedElement.element.addEventListener('change', (event) => {
+ parent.passedElement.element.addEventListener('change', event => {
if (event.detail.value === 'Parent choice 2') {
child.enable();
} else {
@@ -310,8 +409,8 @@
customProperties: {
country: 'Portugal',
},
- }
- ]
+ },
+ ],
});
new Choices('#choices-non-string-values', {
@@ -343,10 +442,12 @@
new Choices('#choices-within-form');
- new Choices('#choices-set-choice-by-value').setChoiceByValue('Choice 2');
+ new Choices('#choices-set-choice-by-value').setChoiceByValue(
+ 'Choice 2',
+ );
new Choices('#choices-search-by-label', { searchFields: ['label'] });
});
-
+
diff --git a/src/scripts/choices.js b/src/scripts/choices.js
index 9e2a865..b8d7213 100644
--- a/src/scripts/choices.js
+++ b/src/scripts/choices.js
@@ -31,7 +31,6 @@ import {
sortByScore,
generateId,
findAncestorByAttrName,
- fetchFromObject,
isIE11,
existsInArray,
cloneObject,
@@ -44,6 +43,10 @@ const USER_DEFAULTS = /** @type {Partial
*/
+
+/**
+ * @typedef {import('../../types/index').Choices.Choice} Choice
+ */
class Choices {
/* ========================================
= Static properties =
@@ -222,7 +225,7 @@ class Choices {
}
/* ========================================
- = Public functions =
+ = Public methods =
======================================== */
init() {
@@ -460,9 +463,93 @@ class Choices {
return this;
}
- setChoices(choices = [], value = '', label = '', replaceChoices = false) {
- if (!this._isSelectElement || !value) {
- return this;
+ /**
+ * Set choices of select input via an array of objects (or function that returns array of object or promise of it),
+ * a value field name and a label field name.
+ * This behaves the same as passing items via the choices option but can be called after initialising Choices.
+ * This can also be used to add groups of choices (see example 2); Optionally pass a true `replaceChoices` value to remove any existing choices.
+ * Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc).
+ *
+ * **Input types affected:** select-one, select-multiple
+ *
+ * @template {object[] | ((instance: Choices) => object[] | Promise)} T
+ * @param {T} [choicesArrayOrFetcher]
+ * @param {string} [value = 'value'] - name of `value` field
+ * @param {string} [label = 'label'] - name of 'label' field
+ * @param {boolean} [replaceChoices = false] - whether to replace of add choices
+ * @returns {this | Promise}
+ *
+ * @example
+ * ```js
+ * const example = new Choices(element);
+ *
+ * example.setChoices([
+ * {value: 'One', label: 'Label One', disabled: true},
+ * {value: 'Two', label: 'Label Two', selected: true},
+ * {value: 'Three', label: 'Label Three'},
+ * ], 'value', 'label', false);
+ * ```
+ *
+ * @example
+ * ```js
+ * const example = new Choices(element);
+ *
+ * example.setChoices(async () => {
+ * try {
+ * const items = await fetch('/items');
+ * return items.json()
+ * } catch(err) {
+ * console.error(err)
+ * }
+ * });
+ * ```
+ *
+ * @example
+ * ```js
+ * const example = new Choices(element);
+ *
+ * example.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', customProperties: {
+ * description: 'Custom description about child six',
+ * random: 'Another random custom property'
+ * }},
+ * ]
+ * }], 'value', 'label', false);
+ * ```
+ */
+ setChoices(
+ choicesArrayOrFetcher = [],
+ value = 'value',
+ label = 'label',
+ replaceChoices = false,
+ ) {
+ if (!this.initialised)
+ throw new ReferenceError(
+ `setChoices was called on a non-initialized instance of Choices`,
+ );
+ if (!this._isSelectElement)
+ throw new TypeError(`setChoices can't be used with INPUT based Choices`);
+
+ if (typeof value !== 'string' || !value) {
+ throw new TypeError(
+ `value parameter must be a name of 'value' field in passed objects`,
+ );
}
// Clear choices if needed
@@ -470,6 +557,34 @@ class Choices {
this.clearChoices();
}
+ if (!Array.isArray(choicesArrayOrFetcher)) {
+ if (typeof choicesArrayOrFetcher !== 'function')
+ throw new TypeError(
+ `.setChoices must be called either with array of choices with a function resulting into Promise of array of choices`,
+ );
+
+ // it's a choices fetcher
+ requestAnimationFrame(() => this._handleLoadingState(true));
+ const fetcher = choicesArrayOrFetcher(this);
+ if (typeof fetcher === 'object' && typeof fetcher.then === 'function') {
+ // that's a promise
+ return fetcher
+ .then(data => this.setChoices(data, value, label, replaceChoices))
+ .catch(err => {
+ if (!this.config.silent) console.error(err);
+ })
+ .then(() => this._handleLoadingState(false))
+ .then(() => this);
+ }
+ // function returned something else than promise, let's check if it's an array of choices
+ if (!Array.isArray(fetcher))
+ throw new TypeError(
+ `.setChoices first argument function must return either array of choices or Promise, got: ${typeof fetcher}`,
+ );
+ // recursion with results, it's sync and choices were cleared already
+ return this.setChoices(fetcher, value, label, false);
+ }
+
this.containerOuter.removeLoadingState();
const addGroupsAndChoices = groupOrChoice => {
if (groupOrChoice.choices) {
@@ -492,7 +607,7 @@ class Choices {
};
this._setLoading(true);
- choices.forEach(addGroupsAndChoices);
+ choicesArrayOrFetcher.forEach(addGroupsAndChoices);
this._setLoading(false);
return this;
@@ -519,18 +634,7 @@ class Choices {
return this;
}
- ajax(fn) {
- if (!this.initialised || !this._isSelectElement || !fn) {
- return this;
- }
-
- requestAnimationFrame(() => this._handleLoadingState(true));
- fn(this._ajaxCallback());
-
- return this;
- }
-
- /* ===== End of Public functions ====== */
+ /* ===== End of Public methods ====== */
/* =============================================
= Private functions =
@@ -1054,55 +1158,6 @@ class Choices {
};
}
- _ajaxCallback() {
- return (results, value, label) => {
- if (!results || !value) {
- return;
- }
-
- const parsedResults = isType('Object', results) ? [results] : results;
-
- if (
- parsedResults &&
- isType('Array', parsedResults) &&
- parsedResults.length
- ) {
- // Remove loading states/text
- this._handleLoadingState(false);
- this._setLoading(true);
- // Add each result as a choice
- parsedResults.forEach(result => {
- if (result.choices) {
- this._addGroup({
- group: result,
- id: result.id || null,
- valueKey: value,
- labelKey: label,
- });
- } else {
- this._addChoice({
- value: fetchFromObject(result, value),
- label: fetchFromObject(result, label),
- isSelected: result.selected,
- isDisabled: result.disabled,
- customProperties: result.customProperties,
- placeholder: result.placeholder,
- });
- }
- });
-
- this._setLoading(false);
-
- if (this._isSelectOneElement) {
- this._selectPlaceholderChoice();
- }
- } else {
- // No results, remove loading state
- this._handleLoadingState(false);
- }
- };
- }
-
_searchChoices(value) {
const newValue = isType('String', value) ? value.trim() : value;
const currentValue = isType('String', this._currentValue)
diff --git a/src/scripts/choices.test.js b/src/scripts/choices.test.js
index 71e116c..d08afb9 100644
--- a/src/scripts/choices.test.js
+++ b/src/scripts/choices.test.js
@@ -881,84 +881,74 @@ describe('choices', () => {
});
});
- describe('ajax', () => {
- const callbackoutput = 'worked';
-
- let handleLoadingStateStub;
- let ajaxCallbackStub;
-
- const returnsEarly = () => {
- it('returns early', () => {
- expect(handleLoadingStateStub.called).to.equal(false);
- expect(ajaxCallbackStub.called).to.equal(false);
- });
- };
-
- beforeEach(() => {
- handleLoadingStateStub = stub();
- ajaxCallbackStub = stub().returns(callbackoutput);
-
- instance._ajaxCallback = ajaxCallbackStub;
- instance._handleLoadingState = handleLoadingStateStub;
- });
-
- afterEach(() => {
- instance._ajaxCallback.reset();
- instance._handleLoadingState.reset();
- });
-
+ describe('setChoices with callback/Promise', () => {
describe('not initialised', () => {
beforeEach(() => {
instance.initialised = false;
- output = instance.ajax(() => {});
});
- returnsInstance(output);
- returnsEarly();
+ it('should throw', () => {
+ expect(() => instance.setChoices(null)).Throw(ReferenceError);
+ });
});
describe('text element', () => {
beforeEach(() => {
instance._isSelectElement = false;
- output = instance.ajax(() => {});
});
- returnsInstance(output);
- returnsEarly();
+ it('should throw', () => {
+ expect(() => instance.setChoices(null)).Throw(TypeError);
+ });
});
describe('passing invalid function', () => {
beforeEach(() => {
- output = instance.ajax(null);
+ instance._isSelectElement = true;
});
- returnsInstance(output);
- returnsEarly();
+ it('should throw on non function', () => {
+ expect(() => instance.setChoices(null)).Throw(TypeError, /Promise/i);
+ });
+
+ it(`should throw on function that doesn't return promise`, () => {
+ expect(() => instance.setChoices(() => 'boo')).to.throw(
+ TypeError,
+ /promise/i,
+ );
+ });
});
describe('select element', () => {
- let callback;
+ it('fetches and sets choices', async () => {
+ document.body.innerHTML = ' ';
+ const choice = new Choices('#test');
+ const handleLoadingStateSpy = spy(choice, '_handleLoadingState');
- beforeEach(() => {
- instance.initialised = true;
- instance._isSelectElement = true;
- ajaxCallbackStub = stub();
- callback = stub();
- output = instance.ajax(callback);
- });
-
- returnsInstance(output);
-
- it('sets loading state', done => {
- requestAnimationFrame(() => {
- expect(handleLoadingStateStub.called).to.equal(true);
- done();
- });
- });
-
- it('calls passed function with ajax callback', () => {
- expect(callback.called).to.equal(true);
- expect(callback.lastCall.args[0]).to.eql(callbackoutput);
+ let fetcherCalled = false;
+ const fetcher = async inst => {
+ expect(inst).to.eq(choice);
+ fetcherCalled = true;
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ return [
+ { label: 'l1', value: 'v1', customProperties: 'prop1' },
+ { label: 'l2', value: 'v2', customProperties: 'prop2' },
+ ];
+ };
+ expect(choice._store.choices.length).to.equal(0);
+ const promise = choice.setChoices(fetcher);
+ await new Promise(resolve =>
+ requestAnimationFrame(() => {
+ expect(handleLoadingStateSpy.callCount).to.equal(1);
+ resolve();
+ }),
+ );
+ expect(fetcherCalled).to.be.true;
+ const res = await promise;
+ expect(res).to.equal(choice);
+ expect(choice._store.choices[1].value).to.equal('v2');
+ expect(choice._store.choices[1].label).to.equal('l2');
+ expect(choice._store.choices[1].customProperties).to.equal('prop2');
});
});
});
@@ -1353,31 +1343,29 @@ describe('choices', () => {
instance.containerOuter.removeLoadingState.reset();
});
- const returnsEarly = () => {
- it('returns early', () => {
- expect(addGroupStub.called).to.equal(false);
- expect(addChoiceStub.called).to.equal(false);
- expect(clearChoicesStub.called).to.equal(false);
- });
- };
-
describe('when element is not select element', () => {
beforeEach(() => {
instance._isSelectElement = false;
- instance.setChoices(choices, value, label, false);
});
- returnsEarly();
+ it('throws', () => {
+ expect(() =>
+ instance.setChoices(choices, value, label, false),
+ ).to.throw(TypeError, /input/i);
+ });
});
describe('passing invalid arguments', () => {
describe('passing no value', () => {
beforeEach(() => {
instance._isSelectElement = true;
- instance.setChoices(choices, undefined, 'label', false);
});
- returnsEarly();
+ it('throws', () => {
+ expect(() =>
+ instance.setChoices(choices, null, 'label', false),
+ ).to.throw(TypeError, /value/i);
+ });
});
});
diff --git a/src/scripts/lib/utils.js b/src/scripts/lib/utils.js
index 55421da..d0fe6a8 100644
--- a/src/scripts/lib/utils.js
+++ b/src/scripts/lib/utils.js
@@ -142,19 +142,6 @@ export const getWindowHeight = () => {
);
};
-export const fetchFromObject = (object, path) => {
- const index = path.indexOf('.');
-
- if (index > -1) {
- return fetchFromObject(
- object[path.substring(0, index)],
- path.substr(index + 1),
- );
- }
-
- return object[path];
-};
-
export const isIE11 = () =>
!!(
navigator.userAgent.match(/Trident/) &&
diff --git a/src/scripts/lib/utils.test.js b/src/scripts/lib/utils.test.js
index 182eb4a..b53bbd7 100644
--- a/src/scripts/lib/utils.test.js
+++ b/src/scripts/lib/utils.test.js
@@ -10,7 +10,6 @@ import {
sanitise,
sortByAlpha,
sortByScore,
- fetchFromObject,
existsInArray,
cloneObject,
dispatchEvent,
@@ -198,19 +197,6 @@ describe('utils', () => {
});
});
- describe('fetchFromObject', () => {
- it('fetches value from object using given path', () => {
- const object = {
- band: {
- name: 'The Strokes',
- },
- };
-
- const output = fetchFromObject(object, 'band.name');
- expect(output).to.equal(object.band.name);
- });
- });
-
describe('existsInArray', () => {
it('determines whether a value exists within given array', () => {
const values = [
diff --git a/types/index.d.ts b/types/index.d.ts
index d992528..0f64819 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -872,16 +872,47 @@ export default class Choices {
*/
getValue(valueOnly?: boolean): string | string[];
+ /** Direct populate choices
+ *
+ * @param {string[] | Choices.Item[]} items
+ */
+ setValue(items: string[] | Choices.Item[]): this;
+
/**
- * Set choices of select input via an array of objects, a value name and a label name.
+ * Set value of input based on existing Choice. `value` can be either a single string or an array of strings
+ *
+ * **Input types affected:** select-one, select-multiple
+ *
+ * @example
+ * ```
+ * const example = new Choices(element, {
+ * choices: [
+ * {value: 'One', label: 'Label One'},
+ * {value: 'Two', label: 'Label Two', disabled: true},
+ * {value: 'Three', label: 'Label Three'},
+ * ],
+ * });
+ *
+ * example.setChoiceByValue('Two'); // Choice with value of 'Two' has now been selected.
+ * ```
+ */
+ setChoiceByValue(value: string | string[]): this;
+
+ /**
+ * Set choices of select input via an array of objects (or function that returns array of object or promise of it),
+ * a value field name and a label field name.
* This behaves the same as passing items via the choices option but can be called after initialising Choices.
* This can also be used to add groups of choices (see example 2); Optionally pass a true `replaceChoices` value to remove any existing choices.
* Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc).
*
* **Input types affected:** select-one, select-multiple
*
- * @example Example 1:
- * ```
+ * @param {string} [value = 'value'] - name of `value` field
+ * @param {string} [label = 'label'] - name of 'label' field
+ * @param {boolean} [replaceChoices = false] - whether to replace of add choices
+ *
+ * @example
+ * ```js
* const example = new Choices(element);
*
* example.setChoices([
@@ -891,8 +922,22 @@ export default class Choices {
* ], 'value', 'label', false);
* ```
*
- * @example Example 2:
+ * @example
+ * ```js
+ * const example = new Choices(element);
+ *
+ * example.setChoices(async () => {
+ * try {
+ * const items = await fetch('/items');
+ * return items.json()
+ * } catch(err) {
+ * console.error(err)
+ * }
+ * });
* ```
+ *
+ * @example
+ * ```js
* const example = new Choices(element);
*
* example.setChoices([{
@@ -920,35 +965,14 @@ export default class Choices {
* }], 'value', 'label', false);
* ```
*/
- setValue(args: string[]): this;
-
- /**
- * Set value of input based on existing Choice. `value` can be either a single string or an array of strings
- *
- * **Input types affected:** select-one, select-multiple
- *
- * @example
- * ```
- * const example = new Choices(element, {
- * choices: [
- * {value: 'One', label: 'Label One'},
- * {value: 'Two', label: 'Label Two', disabled: true},
- * {value: 'Three', label: 'Label Three'},
- * ],
- * });
- *
- * example.setChoiceByValue('Two'); // Choice with value of 'Two' has now been selected.
- * ```
- */
- setChoiceByValue(value: string | string[]): this;
-
- /** Direct populate choices */
- setChoices(
- choices: Choices.Choice[],
- value: string,
- label: string,
+ setChoices<
+ T extends object[] | ((instance: Choices) => object[] | Promise)
+ >(
+ choices: T,
+ value?: string,
+ label?: string,
replaceChoices?: boolean,
- ): this;
+ ): T extends object[] ? this : Promise;
/**
* Clear all choices from select.
@@ -984,28 +1008,4 @@ export default class Choices {
* **Input types affected:** text, select-one, select-multiple
*/
disable(): this;
-
- /**
- * Populate choices/groups via a callback.
- *
- * **Input types affected:** select-one, select-multiple
- *
- * @example
- * ```
- * var example = new Choices(element);
- *
- * example.ajax(function(callback) {
- * fetch(url)
- * .then(function(response) {
- * response.json().then(function(data) {
- * callback(data, 'value', 'label');
- * });
- * })
- * .catch(function(error) {
- * console.log(error);
- * });
- * });
- * ```
- */
- ajax(fn: (values: any) => any): this;
}