Add missing type definitions + rename sortFn (#734)

* Add wrapped element getters + fix some types

* Remove comment

* Add missing config options to types

* Add types to constants

* Rename sortFn to sorter

* Update PR template

* Add refactor to PR template

* Add passed element types to constants

* Add js doc comments to actions

* Add "returns" to js doc comments

* Add missing choice prop to type

* Add types to store.js

* Add jsdoc comments to components

* Ignore strict null checks

* Move loading action into misc.js

* Further type def additions

* Rename itemCompare to valueCompare

* Update badges

* Rename scrollToChoice to scrollToChildElement
This commit is contained in:
Josh Johnson 2019-11-03 13:18:16 +00:00 committed by GitHub
parent 452c8fa666
commit e6882f3e4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 513 additions and 246 deletions

View File

@ -4,12 +4,6 @@
<!--- Why is this change required? What problem does it solve? --> <!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. --> <!--- If it fixes an open issue, please link to the issue here. -->
## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->
## Screenshots (if appropriate) ## Screenshots (if appropriate)
## Types of changes ## Types of changes
@ -17,6 +11,7 @@
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> <!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Chore (tooling change or documentation change) - [ ] Chore (tooling change or documentation change)
- [ ] Refactor (non-breaking change which maintains existing functionality)
- [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality) - [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

View File

@ -1,4 +1,4 @@
# Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Unit%20Tests/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js) [![codebeat badge](https://codebeat.co/badges/55120150-5866-42d8-8010-6aaaff5d3fa1)](https://codebeat.co/projects/github-com-jshjohnson-choices-master) # Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Build%20and%20test/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Bundle%20size%20checks/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js)
A vanilla, lightweight (~19kb gzipped 🎉), configurable select box/text input plugin. Similar to Select2 and Selectize but without the jQuery dependency. A vanilla, lightweight (~19kb gzipped 🎉), configurable select box/text input plugin. Similar to Select2 and Selectize but without the jQuery dependency.
@ -105,7 +105,7 @@ Or include Choices directly:
resetScrollPosition: true, resetScrollPosition: true,
shouldSort: true, shouldSort: true,
shouldSortItems: false, shouldSortItems: false,
sortFn: () => {...}, sorter: () => {...},
placeholder: true, placeholder: true,
placeholderValue: null, placeholderValue: null,
searchPlaceholderValue: null, searchPlaceholderValue: null,
@ -122,8 +122,8 @@ Or include Choices directly:
maxItemText: (maxItemCount) => { maxItemText: (maxItemCount) => {
return `Only ${maxItemCount} values can be added`; return `Only ${maxItemCount} values can be added`;
}, },
itemComparer: (choice, item) => { valueComparer: (value1, value2) => {
return choice === item; return value1 === value2;
}, },
classNames: { classNames: {
containerOuter: 'choices', containerOuter: 'choices',
@ -408,7 +408,7 @@ new Choices(element, {
**Usage:** Whether items should be sorted. If false, items will appear in the order they were selected. **Usage:** Whether items should be sorted. If false, items will appear in the order they were selected.
### sortFn ### sorter
**Type:** `Function` **Default:** sortByAlpha **Type:** `Function` **Default:** sortByAlpha
@ -421,7 +421,7 @@ new Choices(element, {
```js ```js
// Sorting via length of label from largest to smallest // Sorting via length of label from largest to smallest
const example = new Choices(element, { const example = new Choices(element, {
sortFn: function(a, b) { sorter: function(a, b) {
return b.label.length - a.label.length; return b.label.length - a.label.length;
}, },
}; };
@ -536,13 +536,21 @@ For backward compatibility, `<option placeholder>This is a placeholder</option>`
**Usage:** The text that is shown when a user has focus on the input but has already reached the [max item count](https://github.com/jshjohnson/Choices#maxitemcount). To access the max item count, pass a function with a `maxItemCount` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example), otherwise pass a string. **Usage:** The text that is shown when a user has focus on the input but has already reached the [max item count](https://github.com/jshjohnson/Choices#maxitemcount). To access the max item count, pass a function with a `maxItemCount` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example), otherwise pass a string.
### itemComparer ### valueComparer
**Type:** `Function` **Default:** `strict equality` **Type:** `Function` **Default:** `strict equality`
**Input types affected:** `select-one`, `select-multiple` **Input types affected:** `select-one`, `select-multiple`
**Usage:** Compare choice and value in appropriate way (e.g. deep equality for objects). To compare choice and value, pass a function with a `itemComparer` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example). **Usage:** A custom compare function used when finding choices by value (using `setChoiceByValue`).
**Example:**
```js
const example = new Choices(element, {
valueComparer: (a, b) => value.trim() === b.trim(),
};
```
### classNames ### classNames

View File

@ -11,6 +11,7 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"strictNullChecks": false
} }
} }

View File

@ -1,5 +1,13 @@
/**
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
/**
* @argument {Choice} choice
* @returns {{ type: string } & Choice}
*/
export const addChoice = ({ export const addChoice = ({
value, value,
label, label,
@ -23,16 +31,27 @@ export const addChoice = ({
keyCode, keyCode,
}); });
/**
* @argument {Choice[]} results
* @returns {{ type: string, results: Choice[] }}
*/
export const filterChoices = results => ({ export const filterChoices = results => ({
type: ACTION_TYPES.FILTER_CHOICES, type: ACTION_TYPES.FILTER_CHOICES,
results, results,
}); });
/**
* @argument {boolean} active
* @returns {{ type: string, active: boolean }}
*/
export const activateChoices = (active = true) => ({ export const activateChoices = (active = true) => ({
type: ACTION_TYPES.ACTIVATE_CHOICES, type: ACTION_TYPES.ACTIVATE_CHOICES,
active, active,
}); });
/**
* @returns {{ type: string }}
*/
export const clearChoices = () => ({ export const clearChoices = () => ({
type: ACTION_TYPES.CLEAR_CHOICES, type: ACTION_TYPES.CLEAR_CHOICES,
}); });

View File

@ -1,4 +0,0 @@
export const setIsLoading = isLoading => ({
type: 'SET_IS_LOADING',
isLoading,
});

View File

@ -1,28 +0,0 @@
import { expect } from 'chai';
import * as actions from './general';
describe('actions/general', () => {
describe('setIsLoading action', () => {
describe('setting loading state to true', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: true,
};
expect(actions.setIsLoading(true)).to.eql(expectedAction);
});
});
describe('setting loading state to false', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: false,
};
expect(actions.setIsLoading(false)).to.eql(expectedAction);
});
});
});
});

View File

@ -1,6 +1,14 @@
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
export const addGroup = (value, id, active, disabled) => ({ /**
* @typedef {import('../../../types/index').Choices.Group} Group
*/
/**
* @param {Group} group
* @returns {{ type: string } & Group}
*/
export const addGroup = ({ value, id, active, disabled }) => ({
type: ACTION_TYPES.ADD_GROUP, type: ACTION_TYPES.ADD_GROUP,
value, value,
id, id,

View File

@ -16,7 +16,7 @@ describe('actions/groups', () => {
disabled, disabled,
}; };
expect(actions.addGroup(value, id, active, disabled)).to.eql( expect(actions.addGroup({ value, id, active, disabled })).to.eql(
expectedAction, expectedAction,
); );
}); });

View File

@ -1,5 +1,13 @@
import { ACTION_TYPES } from '../constants'; import { ACTION_TYPES } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.Item} Item
*/
/**
* @param {Item} item
* @returns {{ type: string } & Item}
*/
export const addItem = ({ export const addItem = ({
value, value,
label, label,
@ -21,12 +29,22 @@ export const addItem = ({
keyCode, keyCode,
}); });
/**
* @param {string} id
* @param {string} choiceId
* @returns {{ type: string, id: string, choiceId: string }}
*/
export const removeItem = (id, choiceId) => ({ export const removeItem = (id, choiceId) => ({
type: ACTION_TYPES.REMOVE_ITEM, type: ACTION_TYPES.REMOVE_ITEM,
id, id,
choiceId, choiceId,
}); });
/**
* @param {string} id
* @param {boolean} highlighted
* @returns {{ type: string, id: string, highlighted: boolean }}
*/
export const highlightItem = (id, highlighted) => ({ export const highlightItem = (id, highlighted) => ({
type: ACTION_TYPES.HIGHLIGHT_ITEM, type: ACTION_TYPES.HIGHLIGHT_ITEM,
id, id,

View File

@ -9,8 +9,8 @@ describe('actions/items', () => {
const id = '1234'; const id = '1234';
const choiceId = '1234'; const choiceId = '1234';
const groupId = 'test'; const groupId = 'test';
const customProperties = 'test'; const customProperties = { test: true };
const placeholder = 'test'; const placeholder = true;
const keyCode = 10; const keyCode = 10;
const expectedAction = { const expectedAction = {

View File

@ -1,8 +1,24 @@
/**
* @returns {{ type: string }}
*/
export const clearAll = () => ({ export const clearAll = () => ({
type: 'CLEAR_ALL', type: 'CLEAR_ALL',
}); });
/**
* @param {any} state
* @returns {{ type: string, state: object }}
*/
export const resetTo = state => ({ export const resetTo = state => ({
type: 'RESET_TO', type: 'RESET_TO',
state, state,
}); });
/**
* @param {boolean} isLoading
* @returns {{ type: string, isLoading: boolean }}
*/
export const setIsLoading = isLoading => ({
type: 'SET_IS_LOADING',
isLoading,
});

View File

@ -11,4 +11,40 @@ describe('actions/misc', () => {
expect(actions.clearAll()).to.eql(expectedAction); expect(actions.clearAll()).to.eql(expectedAction);
}); });
}); });
describe('resetTo action', () => {
it('returns RESET_TO action', () => {
const state = { test: true };
const expectedAction = {
type: 'RESET_TO',
state,
};
expect(actions.resetTo(state)).to.eql(expectedAction);
});
});
describe('setIsLoading action', () => {
describe('setting loading state to true', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: true,
};
expect(actions.setIsLoading(true)).to.eql(expectedAction);
});
});
describe('setting loading state to false', () => {
it('returns expected action', () => {
const expectedAction = {
type: 'SET_IS_LOADING',
isLoading: false,
};
expect(actions.setIsLoading(false)).to.eql(expectedAction);
});
});
});
}); });

View File

@ -10,7 +10,14 @@ import {
WrappedInput, WrappedInput,
WrappedSelect, WrappedSelect,
} from './components'; } from './components';
import { DEFAULT_CONFIG, EVENTS, KEY_CODES } from './constants'; import {
DEFAULT_CONFIG,
EVENTS,
KEY_CODES,
TEXT_TYPE,
SELECT_ONE_TYPE,
SELECT_MULTIPLE_TYPE,
} from './constants';
import { TEMPLATES } from './templates'; import { TEMPLATES } from './templates';
import { import {
addChoice, addChoice,
@ -20,8 +27,7 @@ import {
} from './actions/choices'; } from './actions/choices';
import { addItem, removeItem, highlightItem } from './actions/items'; import { addItem, removeItem, highlightItem } from './actions/items';
import { addGroup } from './actions/groups'; import { addGroup } from './actions/groups';
import { clearAll, resetTo } from './actions/misc'; import { clearAll, resetTo, setIsLoading } from './actions/misc';
import { setIsLoading } from './actions/general';
import { import {
isScrolledIntoView, isScrolledIntoView,
getAdjacentEl, getAdjacentEl,
@ -37,16 +43,18 @@ import {
diff, diff,
} from './lib/utils'; } from './lib/utils';
const USER_DEFAULTS = /** @type {Partial<import('../../types/index').Choices.Options>} */ ({}); /**
* @typedef {import('../../types/index').Choices.Choice} Choice
* @typedef {import('../../types/index').Choices.Options} Options
*/
/** @type {Partial<Options>} */
const USER_DEFAULTS = {};
/** /**
* Choices * Choices
* @author Josh Johnson<josh@joshuajohnson.co.uk> * @author Josh Johnson<josh@joshuajohnson.co.uk>
*/ */
/**
* @typedef {import('../../types/index').Choices.Choice} Choice
*/
class Choices { class Choices {
static get defaults() { static get defaults() {
return Object.preventExtensions({ return Object.preventExtensions({
@ -61,14 +69,15 @@ class Choices {
/** /**
* @param {string | HTMLInputElement | HTMLSelectElement} element * @param {string | HTMLInputElement | HTMLSelectElement} element
* @param {Partial<import('../../types/index').Choices.Options>} userConfig * @param {Partial<Options>} userConfig
*/ */
constructor(element = '[data-choice]', userConfig = {}) { constructor(element = '[data-choice]', userConfig = {}) {
/** @type {Partial<Options>} */
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,
// instead of concatenating with the default array // instead of concatenating with the default array
{ arrayMerge: (destinationArray, sourceArray) => [...sourceArray] }, { arrayMerge: (_, sourceArray) => [...sourceArray] },
); );
// Convert addItemFilter to function // Convert addItemFilter to function
@ -110,9 +119,9 @@ class Choices {
); );
} }
this._isTextElement = passedElement.type === 'text'; this._isTextElement = passedElement.type === TEXT_TYPE;
this._isSelectOneElement = passedElement.type === 'select-one'; this._isSelectOneElement = passedElement.type === SELECT_ONE_TYPE;
this._isSelectMultipleElement = passedElement.type === 'select-multiple'; this._isSelectMultipleElement = passedElement.type === SELECT_MULTIPLE_TYPE;
this._isSelectElement = this._isSelectElement =
this._isSelectOneElement || this._isSelectMultipleElement; this._isSelectOneElement || this._isSelectMultipleElement;
@ -148,7 +157,7 @@ class Choices {
* or when calculated direction is different from the document * or when calculated direction is different from the document
* @type {HTMLElement['dir']} * @type {HTMLElement['dir']}
*/ */
this._direction = this.passedElement.element.dir; this._direction = this.passedElement.dir;
if (!this._direction) { if (!this._direction) {
const { direction: elementDirection } = window.getComputedStyle( const { direction: elementDirection } = window.getComputedStyle(
@ -193,16 +202,8 @@ 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.shouldSortItems === true && this._isSelectOneElement) {
if (!this.config.silent) {
console.warn(
"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.isActive) {
if (!this.config.silent) { if (!this.config.silent) {
console.warn( console.warn(
'Trying to initialise Choices on element already initialised', 'Trying to initialise Choices on element already initialised',
@ -781,7 +782,7 @@ class Choices {
// If sorting is enabled, filter groups // If sorting is enabled, filter groups
if (this.config.shouldSort) { if (this.config.shouldSort) {
groups.sort(this.config.sortFn); groups.sort(this.config.sorter);
} }
groups.forEach(group => { groups.forEach(group => {
@ -807,7 +808,7 @@ class Choices {
searchResultLimit, searchResultLimit,
renderChoiceLimit, renderChoiceLimit,
} = this.config; } = this.config;
const filter = this._isSearching ? sortByScore : this.config.sortFn; const filter = this._isSearching ? sortByScore : this.config.sorter;
const appendChoice = choice => { const appendChoice = choice => {
const shouldRender = const shouldRender =
renderSelectedChoices === 'auto' renderSelectedChoices === 'auto'
@ -857,7 +858,7 @@ class Choices {
if (this._isSearching) { if (this._isSearching) {
choiceLimit = searchResultLimit; choiceLimit = searchResultLimit;
} else if (renderChoiceLimit > 0 && !withinGroup) { } else if (renderChoiceLimit && renderChoiceLimit > 0 && !withinGroup) {
choiceLimit = renderChoiceLimit; choiceLimit = renderChoiceLimit;
} }
@ -873,11 +874,11 @@ class Choices {
_createItemsFragment(items, fragment = document.createDocumentFragment()) { _createItemsFragment(items, fragment = document.createDocumentFragment()) {
// Create fragment to add elements to // Create fragment to add elements to
const { shouldSortItems, sortFn, removeItemButton } = this.config; const { shouldSortItems, sorter, removeItemButton } = this.config;
// If sorting is enabled, filter items // If sorting is enabled, filter items
if (shouldSortItems && !this._isSelectOneElement) { if (shouldSortItems && !this._isSelectOneElement) {
items.sort(sortFn); items.sort(sorter);
} }
if (this._isTextElement) { if (this._isTextElement) {
@ -896,7 +897,7 @@ class Choices {
}; };
// Add each list item to list // Add each list item to list
items.forEach(item => addItemToFragment(item)); items.forEach(addItemToFragment);
return fragment; return fragment;
} }
@ -1501,7 +1502,7 @@ class Choices {
if ( if (
!isScrolledIntoView(nextEl, this.choiceList.element, directionInt) !isScrolledIntoView(nextEl, this.choiceList.element, directionInt)
) { ) {
this.choiceList.scrollToChoice(nextEl, directionInt); this.choiceList.scrollToChildElement(nextEl, directionInt);
} }
this._highlightChoice(nextEl); this._highlightChoice(nextEl);
} }
@ -1640,18 +1641,18 @@ class Choices {
} }
const focusActions = { const focusActions = {
text: () => { [TEXT_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.addFocusState(); this.containerOuter.addFocusState();
} }
}, },
'select-one': () => { [SELECT_ONE_TYPE]: () => {
this.containerOuter.addFocusState(); this.containerOuter.addFocusState();
if (target === this.input.element) { if (target === this.input.element) {
this.showDropdown(true); this.showDropdown(true);
} }
}, },
'select-multiple': () => { [SELECT_MULTIPLE_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.showDropdown(true); this.showDropdown(true);
// If element is a select box, the focused element is the container and the dropdown // If element is a select box, the focused element is the container and the dropdown
@ -1671,7 +1672,7 @@ class Choices {
const { activeItems } = this._store; const { activeItems } = this._store;
const hasHighlightedItems = activeItems.some(item => item.highlighted); const hasHighlightedItems = activeItems.some(item => item.highlighted);
const blurActions = { const blurActions = {
text: () => { [TEXT_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
if (hasHighlightedItems) { if (hasHighlightedItems) {
@ -1680,7 +1681,7 @@ class Choices {
this.hideDropdown(true); this.hideDropdown(true);
} }
}, },
'select-one': () => { [SELECT_ONE_TYPE]: () => {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
if ( if (
target === this.input.element || target === this.input.element ||
@ -1689,7 +1690,7 @@ class Choices {
this.hideDropdown(true); this.hideDropdown(true);
} }
}, },
'select-multiple': () => { [SELECT_MULTIPLE_TYPE]: () => {
if (target === this.input.element) { if (target === this.input.element) {
this.containerOuter.removeFocusState(); this.containerOuter.removeFocusState();
this.hideDropdown(true); this.hideDropdown(true);
@ -1906,7 +1907,14 @@ class Choices {
const isDisabled = group.disabled ? group.disabled : false; const isDisabled = group.disabled ? group.disabled : false;
if (groupChoices) { if (groupChoices) {
this._store.dispatch(addGroup(group.label, groupId, true, isDisabled)); this._store.dispatch(
addGroup({
value: group.label,
id: groupId,
active: true,
disabled: isDisabled,
}),
);
const addGroupChoices = choice => { const addGroupChoices = choice => {
const isOptDisabled = const isOptDisabled =
@ -1926,7 +1934,12 @@ class Choices {
groupChoices.forEach(addGroupChoices); groupChoices.forEach(addGroupChoices);
} else { } else {
this._store.dispatch( this._store.dispatch(
addGroup(group.label, group.id, false, group.disabled), addGroup({
value: group.label,
id: group.id,
active: false,
disabled: group.disabled,
}),
); );
} }
} }
@ -2069,7 +2082,7 @@ class Choices {
); );
} else { } else {
const passedOptions = this.passedElement.options; const passedOptions = this.passedElement.options;
const filter = this.config.sortFn; const filter = this.config.sorter;
const allChoices = this._presetChoices; const allChoices = this._presetChoices;
// Create array of options from option elements // Create array of options from option elements
@ -2211,7 +2224,7 @@ class Choices {
const { choices } = this._store; const { choices } = this._store;
// Check 'value' property exists and the choice isn't already selected // Check 'value' property exists and the choice isn't already selected
const foundChoice = choices.find(choice => const foundChoice = choices.find(choice =>
this.config.itemComparer(choice.value, val), this.config.valueComparer(choice.value, val),
); );
if (foundChoice && !foundChoice.selected) { if (foundChoice && !foundChoice.selected) {
@ -2251,8 +2264,6 @@ class Choices {
return false; return false;
} }
/* ===== End of Private functions ====== */
} }
export default Choices; export default Choices;

View File

@ -1730,15 +1730,15 @@ describe('choices', () => {
beforeEach(() => { beforeEach(() => {
sortFnStub = stub(); sortFnStub = stub();
instance.config.sortFn = sortFnStub; instance.config.sorter = sortFnStub;
instance.config.shouldSort = true; instance.config.shouldSort = true;
}); });
afterEach(() => { afterEach(() => {
instance.config.sortFn.reset(); instance.config.sorter.reset();
}); });
it('sorts groups by config.sortFn', () => { it('sorts groups by config.sorter', () => {
expect(sortFnStub.called).to.equal(false); expect(sortFnStub.called).to.equal(false);
instance._createGroupsFragment(groups, choices); instance._createGroupsFragment(groups, choices);
expect(sortFnStub.called).to.equal(true); expect(sortFnStub.called).to.equal(true);
@ -1750,12 +1750,12 @@ describe('choices', () => {
beforeEach(() => { beforeEach(() => {
sortFnStub = stub(); sortFnStub = stub();
instance.config.sortFn = sortFnStub; instance.config.sorter = sortFnStub;
instance.config.shouldSort = false; instance.config.shouldSort = false;
}); });
afterEach(() => { afterEach(() => {
instance.config.sortFn.reset(); instance.config.sorter.reset();
}); });
it('does not sort groups', () => { it('does not sort groups', () => {

View File

@ -1,42 +1,47 @@
import { wrap } from '../lib/utils'; import { wrap } from '../lib/utils';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Container { export default class Container {
/**
* @param {{
* element: HTMLElement,
* type: passedElement['type'],
* classNames: ClassNames,
* position
* }} args
*/
constructor({ element, type, classNames, position }) { constructor({ element, type, classNames, position }) {
Object.assign(this, { element, classNames, type, position }); this.element = element;
this.classNames = classNames;
this.type = type;
this.position = position;
this.isOpen = false; this.isOpen = false;
this.isFlipped = false; this.isFlipped = false;
this.isFocussed = false; this.isFocussed = false;
this.isDisabled = false; this.isDisabled = false;
this.isLoading = false; this.isLoading = false;
this._onFocus = this._onFocus.bind(this); this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this); this._onBlur = this._onBlur.bind(this);
} }
/**
* Add event listeners
*/
addEventListeners() { addEventListeners() {
this.element.addEventListener('focus', this._onFocus); this.element.addEventListener('focus', this._onFocus);
this.element.addEventListener('blur', this._onBlur); this.element.addEventListener('blur', this._onBlur);
} }
/**
* Remove event listeners
*/
/** */
removeEventListeners() { removeEventListeners() {
this.element.removeEventListener('focus', this._onFocus); this.element.removeEventListener('focus', this._onFocus);
this.element.removeEventListener('blur', this._onBlur); this.element.removeEventListener('blur', this._onBlur);
} }
/** /**
* Determine whether container should be flipped * Determine whether container should be flipped based on passed
* based on passed dropdown position * dropdown position
* @param {Number} dropdownPos * @param {number} dropdownPos
* @returns * @returns {boolean}
*/ */
shouldFlip(dropdownPos) { shouldFlip(dropdownPos) {
if (typeof dropdownPos !== 'number') { if (typeof dropdownPos !== 'number') {
@ -57,20 +62,19 @@ export default class Container {
} }
/** /**
* Set active descendant attribute * @param {string} activeDescendantID
* @param {Number} activeDescendant ID of active descendant
*/ */
setActiveDescendant(activeDescendantID) { setActiveDescendant(activeDescendantID) {
this.element.setAttribute('aria-activedescendant', activeDescendantID); this.element.setAttribute('aria-activedescendant', activeDescendantID);
} }
/**
* Remove active descendant attribute
*/
removeActiveDescendant() { removeActiveDescendant() {
this.element.removeAttribute('aria-activedescendant'); this.element.removeAttribute('aria-activedescendant');
} }
/**
* @param {number} dropdownPos
*/
open(dropdownPos) { open(dropdownPos) {
this.element.classList.add(this.classNames.openState); this.element.classList.add(this.classNames.openState);
this.element.setAttribute('aria-expanded', 'true'); this.element.setAttribute('aria-expanded', 'true');
@ -109,9 +113,6 @@ export default class Container {
this.element.classList.remove(this.classNames.focusState); this.element.classList.remove(this.classNames.focusState);
} }
/**
* Remove disabled state
*/
enable() { enable() {
this.element.classList.remove(this.classNames.disabledState); this.element.classList.remove(this.classNames.disabledState);
this.element.removeAttribute('aria-disabled'); this.element.removeAttribute('aria-disabled');
@ -121,9 +122,6 @@ export default class Container {
this.isDisabled = false; this.isDisabled = false;
} }
/**
* Set disabled state
*/
disable() { disable() {
this.element.classList.add(this.classNames.disabledState); this.element.classList.add(this.classNames.disabledState);
this.element.setAttribute('aria-disabled', 'true'); this.element.setAttribute('aria-disabled', 'true');
@ -133,10 +131,16 @@ export default class Container {
this.isDisabled = true; this.isDisabled = true;
} }
/**
* @param {HTMLElement} element
*/
wrap(element) { wrap(element) {
wrap(element, this.element); wrap(element, this.element);
} }
/**
* @param {Element} element
*/
unwrap(element) { unwrap(element) {
// Move passed element outside this element // Move passed element outside this element
this.element.parentNode.insertBefore(element, this.element); this.element.parentNode.insertBefore(element, this.element);
@ -144,34 +148,22 @@ export default class Container {
this.element.parentNode.removeChild(this.element); this.element.parentNode.removeChild(this.element);
} }
/**
* Add loading state to element
*/
addLoadingState() { addLoadingState() {
this.element.classList.add(this.classNames.loadingState); this.element.classList.add(this.classNames.loadingState);
this.element.setAttribute('aria-busy', 'true'); this.element.setAttribute('aria-busy', 'true');
this.isLoading = true; this.isLoading = true;
} }
/**
* Remove loading state from element
*/
removeLoadingState() { removeLoadingState() {
this.element.classList.remove(this.classNames.loadingState); this.element.classList.remove(this.classNames.loadingState);
this.element.removeAttribute('aria-busy'); this.element.removeAttribute('aria-busy');
this.isLoading = false; this.isLoading = false;
} }
/**
* Set focussed state
*/
_onFocus() { _onFocus() {
this.isFocussed = true; this.isFocussed = true;
} }
/**
* Remove blurred state
*/
_onBlur() { _onBlur() {
this.isFocussed = false; this.isFocussed = false;
} }

View File

@ -1,13 +1,26 @@
export default class Dropdown { /**
constructor({ element, type, classNames }) { * @typedef {import('../../../types/index').Choices.passedElement} passedElement
Object.assign(this, { element, type, classNames }); * @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Dropdown {
/**
* @param {{
* element: HTMLElement,
* type: passedElement['type'],
* classNames: ClassNames,
* }} args
*/
constructor({ element, type, classNames }) {
this.element = element;
this.classNames = classNames;
this.type = type;
this.isActive = false; this.isActive = false;
} }
/** /**
* Bottom position of dropdown in viewport coordinates * Bottom position of dropdown in viewport coordinates
* @type {number} Vertical position * @returns {number} Vertical position
*/ */
get distanceFromTopWindow() { get distanceFromTopWindow() {
return this.element.getBoundingClientRect().bottom; return this.element.getBoundingClientRect().bottom;
@ -15,7 +28,8 @@ export default class Dropdown {
/** /**
* Find element that matches passed selector * Find element that matches passed selector
* @return {HTMLElement} * @param {string} selector
* @returns {HTMLElement | null}
*/ */
getChild(selector) { getChild(selector) {
return this.element.querySelector(selector); return this.element.querySelector(selector);
@ -23,8 +37,7 @@ export default class Dropdown {
/** /**
* Show dropdown to user by adding active state class * Show dropdown to user by adding active state class
* @return {Object} Class instance * @returns {this}
* @public
*/ */
show() { show() {
this.element.classList.add(this.classNames.activeState); this.element.classList.add(this.classNames.activeState);
@ -36,8 +49,7 @@ export default class Dropdown {
/** /**
* Hide dropdown from user * Hide dropdown from user
* @return {Object} Class instance * @returns {this}
* @public
*/ */
hide() { hide() {
this.element.classList.remove(this.classNames.activeState); this.element.classList.remove(this.classNames.activeState);

View File

@ -1,11 +1,18 @@
import { sanitise } from '../lib/utils'; import { sanitise } from '../lib/utils';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class Input { export default class Input {
/** /**
* * @param {{
* @typedef {import('../../../types/index').Choices.passedElement} passedElement * element: HTMLInputElement,
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames * type: passedElement['type'],
* @param {{element: HTMLInputElement, type: passedElement['type'], classNames: ClassNames, preventPaste: boolean }} p * classNames: ClassNames,
* preventPaste: boolean
* }} args
*/ */
constructor({ element, type, classNames, preventPaste }) { constructor({ element, type, classNames, preventPaste }) {
this.element = element; this.element = element;
@ -21,14 +28,23 @@ export default class Input {
this._onBlur = this._onBlur.bind(this); this._onBlur = this._onBlur.bind(this);
} }
/**
* @param {string} placeholder
*/
set placeholder(placeholder) { set placeholder(placeholder) {
this.element.placeholder = placeholder; this.element.placeholder = placeholder;
} }
/**
* @returns {string}
*/
get value() { get value() {
return sanitise(this.element.value); return sanitise(this.element.value);
} }
/**
* @param {string} value
*/
set value(value) { set value(value) {
this.element.value = value; this.element.value = value;
} }
@ -83,8 +99,8 @@ export default class Input {
/** /**
* Set value of input to blank * Set value of input to blank
* @return {Object} Class instance * @param {boolean} setWidth
* @public * @returns {this}
*/ */
clear(setWidth = true) { clear(setWidth = true) {
if (this.element.value) { if (this.element.value) {
@ -109,6 +125,9 @@ export default class Input {
style.width = `${value.length + 1}ch`; style.width = `${value.length + 1}ch`;
} }
/**
* @param {string} activeDescendantID
*/
setActiveDescendant(activeDescendantID) { setActiveDescendant(activeDescendantID) {
this.element.setAttribute('aria-activedescendant', activeDescendantID); this.element.setAttribute('aria-activedescendant', activeDescendantID);
} }
@ -123,6 +142,9 @@ export default class Input {
} }
} }
/**
* @param {Event} event
*/
_onPaste(event) { _onPaste(event) {
if (this.preventPaste) { if (this.preventPaste) {
event.preventDefault(); event.preventDefault();

View File

@ -1,9 +1,14 @@
import { SCROLLING_SPEED } from '../constants'; import { SCROLLING_SPEED } from '../constants';
/**
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
export default class List { export default class List {
/**
* @param {{ element: HTMLElement }} args
*/
constructor({ element }) { constructor({ element }) {
Object.assign(this, { element }); this.element = element;
this.scrollPos = this.element.scrollTop; this.scrollPos = this.element.scrollTop;
this.height = this.element.offsetHeight; this.height = this.element.offsetHeight;
} }
@ -12,14 +17,24 @@ export default class List {
this.element.innerHTML = ''; this.element.innerHTML = '';
} }
/**
* @param {Element} node
*/
append(node) { append(node) {
this.element.appendChild(node); this.element.appendChild(node);
} }
/**
* @param {string} selector
* @returns {Element | null}
*/
getChild(selector) { getChild(selector) {
return this.element.querySelector(selector); return this.element.querySelector(selector);
} }
/**
* @returns {boolean}
*/
hasChildren() { hasChildren() {
return this.element.hasChildNodes(); return this.element.hasChildNodes();
} }
@ -28,28 +43,39 @@ export default class List {
this.element.scrollTop = 0; this.element.scrollTop = 0;
} }
scrollToChoice(choice, direction) { /**
if (!choice) { * @param {HTMLElement} element
* @param {1 | -1} direction
*/
scrollToChildElement(element, direction) {
if (!element) {
return; return;
} }
const dropdownHeight = this.element.offsetHeight; const listHeight = this.element.offsetHeight;
const choiceHeight = choice.offsetHeight;
// Distance from bottom of element to top of parent
const choicePos = choice.offsetTop + choiceHeight;
// Scroll position of dropdown // Scroll position of dropdown
const containerScrollPos = this.element.scrollTop + dropdownHeight; const listScrollPosition = this.element.scrollTop + listHeight;
// Difference between the choice and scroll position
const elementHeight = element.offsetHeight;
// Distance from bottom of element to top of parent
const elementPos = element.offsetTop + elementHeight;
// Difference between the element and scroll position
const destination = const destination =
direction > 0 direction > 0
? this.element.scrollTop + choicePos - containerScrollPos ? this.element.scrollTop + elementPos - listScrollPosition
: choice.offsetTop; : element.offsetTop;
requestAnimationFrame(time => { requestAnimationFrame(() => {
this._animateScroll(time, destination, direction); this._animateScroll(destination, direction);
}); });
} }
/**
* @param {number} scrollPos
* @param {number} strength
* @param {number} destination
*/
_scrollDown(scrollPos, strength, destination) { _scrollDown(scrollPos, strength, destination) {
const easing = (destination - scrollPos) / strength; const easing = (destination - scrollPos) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
@ -57,6 +83,11 @@ export default class List {
this.element.scrollTop = scrollPos + distance; this.element.scrollTop = scrollPos + distance;
} }
/**
* @param {number} scrollPos
* @param {number} strength
* @param {number} destination
*/
_scrollUp(scrollPos, strength, destination) { _scrollUp(scrollPos, strength, destination) {
const easing = (scrollPos - destination) / strength; const easing = (scrollPos - destination) / strength;
const distance = easing > 1 ? easing : 1; const distance = easing > 1 ? easing : 1;
@ -64,7 +95,11 @@ export default class List {
this.element.scrollTop = scrollPos - distance; this.element.scrollTop = scrollPos - distance;
} }
_animateScroll(time, destination, direction) { /**
* @param {*} destination
* @param {*} direction
*/
_animateScroll(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;
@ -85,7 +120,7 @@ export default class List {
if (continueAnimation) { if (continueAnimation) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this._animateScroll(time, destination, direction); this._animateScroll(destination, direction);
}); });
} }
} }

View File

@ -1,16 +1,39 @@
import { dispatchEvent } from '../lib/utils'; import { dispatchEvent } from '../lib/utils';
export default class WrappedElement { /**
constructor({ element, classNames }) { * @typedef {import('../../../types/index').Choices.passedElement} passedElement
Object.assign(this, { element, classNames }); * @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
if (!(element instanceof Element)) { export default class WrappedElement {
/**
* @param {{
* element: HTMLInputElement | HTMLSelectElement,
* classNames: ClassNames,
* }} args
*/
constructor({ element, classNames }) {
this.element = element;
this.classNames = classNames;
if (
!(element instanceof HTMLInputElement) &&
!(element instanceof HTMLSelectElement)
) {
throw new TypeError('Invalid element passed'); throw new TypeError('Invalid element passed');
} }
this.isDisabled = false; this.isDisabled = false;
} }
get isActive() {
return this.element.dataset.choice === 'active';
}
get dir() {
return this.element.dir;
}
get value() { get value() {
return this.element.value; return this.element.value;
} }
@ -26,7 +49,7 @@ export default class WrappedElement {
this.element.hidden = true; this.element.hidden = true;
// Remove element from tab index // Remove element from tab index
this.element.tabIndex = '-1'; this.element.tabIndex = -1;
// Backup original styles if any // Backup original styles if any
const origStyle = this.element.getAttribute('style'); const origStyle = this.element.getAttribute('style');

View File

@ -31,6 +31,19 @@ describe('components/wrappedElement', () => {
it('sets isDisabled flag to false', () => { it('sets isDisabled flag to false', () => {
expect(instance.isDisabled).to.eql(false); expect(instance.isDisabled).to.eql(false);
}); });
describe('passing an element that is not an instance of HTMLInputElement or HTMLSelectElement', () => {
it('throws a TypeError', () => {
element = document.createElement('div');
expect(
() =>
new WrappedElement({
element,
classNames: DEFAULT_CLASSNAMES,
}),
).to.throw(TypeError, 'Invalid element passed');
});
});
}); });
describe('value getter', () => { describe('value getter', () => {
@ -39,6 +52,22 @@ describe('components/wrappedElement', () => {
}); });
}); });
describe('isActive getter', () => {
it('returns whether the "data-choice" attribute is set to "active"', () => {
instance.element.dataset.choice = 'active';
expect(instance.isActive).to.equal(true);
instance.element.dataset.choice = 'inactive';
expect(instance.isActive).to.equal(false);
});
});
describe('dir getter', () => {
it('returns the direction of the element', () => {
expect(instance.dir).to.equal(instance.element.dir);
});
});
describe('conceal', () => { describe('conceal', () => {
let originalStyling; let originalStyling;

View File

@ -1,15 +1,33 @@
import WrappedElement from './wrapped-element'; import WrappedElement from './wrapped-element';
/**
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../../types/index').Choices.Item} Item
*/
export default class WrappedInput extends WrappedElement { export default class WrappedInput extends WrappedElement {
/**
* @param {{
* element: HTMLInputElement,
* classNames: ClassNames,
* delimiter: string
* }} args
*/
constructor({ element, classNames, delimiter }) { constructor({ element, classNames, delimiter }) {
super({ element, classNames }); super({ element, classNames });
this.delimiter = delimiter; this.delimiter = delimiter;
} }
/**
* @returns {string}
*/
get value() { get value() {
return this.element.value; return this.element.value;
} }
/**
* @param {Item[]} items
*/
set value(items) { set value(items) {
const itemValues = items.map(({ value }) => value); const itemValues = items.map(({ value }) => value);
const joinedValues = itemValues.join(this.delimiter); const joinedValues = itemValues.join(this.delimiter);

View File

@ -1,6 +1,20 @@
import WrappedElement from './wrapped-element'; import WrappedElement from './wrapped-element';
/**
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../../types/index').Choices.Item} Item
* @typedef {import('../../../types/index').Choices.Choice} Choice
*/
export default class WrappedSelect extends WrappedElement { export default class WrappedSelect extends WrappedElement {
/**
* @param {{
* element: HTMLSelectElement,
* classNames: ClassNames,
* delimiter: string
* template: function
* }} args
*/
constructor({ element, classNames, template }) { constructor({ element, classNames, template }) {
super({ element, classNames }); super({ element, classNames });
this.template = template; this.template = template;
@ -14,14 +28,23 @@ export default class WrappedSelect extends WrappedElement {
); );
} }
/**
* @returns {Element[]}
*/
get optionGroups() { get optionGroups() {
return Array.from(this.element.getElementsByTagName('OPTGROUP')); return Array.from(this.element.getElementsByTagName('OPTGROUP'));
} }
/**
* @returns {Item[] | Choice[]}
*/
get options() { get options() {
return Array.from(this.element.options); return Array.from(this.element.options);
} }
/**
* @param {Item[] | Choice[]} options
*/
set options(options) { set options(options) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const addOptionToFragment = data => { const addOptionToFragment = data => {
@ -37,6 +60,9 @@ export default class WrappedSelect extends WrappedElement {
this.appendDocFragment(fragment); this.appendDocFragment(fragment);
} }
/**
* @param {DocumentFragment} fragment
*/
appendDocFragment(fragment) { appendDocFragment(fragment) {
this.element.innerHTML = ''; this.element.innerHTML = '';
this.element.appendChild(fragment); this.element.appendChild(fragment);

View File

@ -1,5 +1,11 @@
import { sanitise, sortByAlpha } from './lib/utils'; import { sanitise, sortByAlpha } from './lib/utils';
/**
* @typedef {import('../../types/index').Choices.ClassNames} ClassNames
* @typedef {import('../../types/index').Choices.Options} Options
*/
/** @type {ClassNames} */
export const DEFAULT_CLASSNAMES = { export const DEFAULT_CLASSNAMES = {
containerOuter: 'choices', containerOuter: 'choices',
containerInner: 'choices__inner', containerInner: 'choices__inner',
@ -28,6 +34,7 @@ export const DEFAULT_CLASSNAMES = {
noChoices: 'has-no-choices', noChoices: 'has-no-choices',
}; };
/** @type {Options} */
export const DEFAULT_CONFIG = { export const DEFAULT_CONFIG = {
items: [], items: [],
choices: [], choices: [],
@ -51,7 +58,7 @@ export const DEFAULT_CONFIG = {
resetScrollPosition: true, resetScrollPosition: true,
shouldSort: true, shouldSort: true,
shouldSortItems: false, shouldSortItems: false,
sortFn: sortByAlpha, sorter: sortByAlpha,
placeholder: true, placeholder: true,
placeholderValue: null, placeholderValue: null,
searchPlaceholderValue: null, searchPlaceholderValue: null,
@ -66,7 +73,7 @@ export const DEFAULT_CONFIG = {
customAddItemText: 'Only values matching specific conditions can be added', customAddItemText: 'Only values matching specific conditions can be added',
addItemText: value => `Press Enter to add <b>"${sanitise(value)}"</b>`, addItemText: value => `Press Enter to add <b>"${sanitise(value)}"</b>`,
maxItemText: maxItemCount => `Only ${maxItemCount} values can be added`, maxItemText: maxItemCount => `Only ${maxItemCount} values can be added`,
itemComparer: (choice, item) => choice === item, valueComparer: (value1, value2) => value1 === value2,
fuseOptions: { fuseOptions: {
includeScore: true, includeScore: true,
}, },
@ -111,4 +118,8 @@ export const KEY_CODES = {
PAGE_DOWN_KEY: 34, PAGE_DOWN_KEY: 34,
}; };
export const TEXT_TYPE = 'text';
export const SELECT_ONE_TYPE = 'select-one';
export const SELECT_MULTIPLE_TYPE = 'select-multiple';
export const SCROLLING_SPEED = 4; export const SCROLLING_SPEED = 4;

View File

@ -108,19 +108,25 @@ export const strToEl = (() => {
}; };
})(); })();
export const sortByAlpha = /**
/** * @param {{ label?: string, value: string }} a
* @param {{ label?: string, value: string }} a * @param {{ label?: string, value: string }} b
* @param {{ label?: string, value: string }} b * @returns {number}
* @returns {number} */
*/ export const sortByAlpha = (
({ value, label = value }, { value: value2, label: label2 = value2 }) => { value, label = value },
label.localeCompare(label2, [], { { value: value2, label: label2 = value2 },
sensitivity: 'base', ) =>
ignorePunctuation: true, label.localeCompare(label2, [], {
numeric: true, sensitivity: 'base',
}); ignorePunctuation: true,
numeric: true,
});
/**
* @param {object} a
* @param {object} b
*/
export const sortByScore = (a, b) => a.score - b.score; export const sortByScore = (a, b) => a.score - b.score;
export const dispatchEvent = (element, type, customArgs = null) => { export const dispatchEvent = (element, type, customArgs = null) => {

View File

@ -1,6 +1,12 @@
import { createStore } from 'redux'; import { createStore } from 'redux';
import rootReducer from '../reducers/index'; import rootReducer from '../reducers/index';
/**
* @typedef {import('../../../types/index').Choices.Choice} Choice
* @typedef {import('../../../types/index').Choices.Group} Group
* @typedef {import('../../../types/index').Choices.Item} Item
*/
export default class Store { export default class Store {
constructor() { constructor() {
this._store = createStore( this._store = createStore(
@ -30,7 +36,7 @@ export default class Store {
/** /**
* Get store object (wrapping Redux method) * Get store object (wrapping Redux method)
* @return {Object} State * @returns {object} State
*/ */
get state() { get state() {
return this._store.getState(); return this._store.getState();
@ -38,7 +44,7 @@ export default class Store {
/** /**
* Get items from store * Get items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get items() { get items() {
return this.state.items; return this.state.items;
@ -46,7 +52,7 @@ export default class Store {
/** /**
* Get active items from store * Get active items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get activeItems() { get activeItems() {
return this.items.filter(item => item.active === true); return this.items.filter(item => item.active === true);
@ -54,7 +60,7 @@ export default class Store {
/** /**
* Get highlighted items from store * Get highlighted items from store
* @return {Array} Item objects * @returns {Item[]} Item objects
*/ */
get highlightedActiveItems() { get highlightedActiveItems() {
return this.items.filter(item => item.active && item.highlighted); return this.items.filter(item => item.active && item.highlighted);
@ -62,7 +68,7 @@ export default class Store {
/** /**
* Get choices from store * Get choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get choices() { get choices() {
return this.state.choices; return this.state.choices;
@ -70,18 +76,15 @@ export default class Store {
/** /**
* Get active choices from store * Get active choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get activeChoices() { get activeChoices() {
const { choices } = this; return this.choices.filter(choice => choice.active === true);
const values = choices.filter(choice => choice.active === true);
return values;
} }
/** /**
* Get selectable choices from store * Get selectable choices from store
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get selectableChoices() { get selectableChoices() {
return this.choices.filter(choice => choice.disabled !== true); return this.choices.filter(choice => choice.disabled !== true);
@ -89,7 +92,7 @@ export default class Store {
/** /**
* Get choices that can be searched (excluding placeholders) * Get choices that can be searched (excluding placeholders)
* @return {Array} Option objects * @returns {Choice[]} Option objects
*/ */
get searchableChoices() { get searchableChoices() {
return this.selectableChoices.filter(choice => choice.placeholder !== true); return this.selectableChoices.filter(choice => choice.placeholder !== true);
@ -97,7 +100,7 @@ export default class Store {
/** /**
* Get placeholder choice from store * Get placeholder choice from store
* @return {Object} Found placeholder * @returns {Choice | undefined} Found placeholder
*/ */
get placeholderChoice() { get placeholderChoice() {
return [...this.choices] return [...this.choices]
@ -107,7 +110,7 @@ export default class Store {
/** /**
* Get groups from store * Get groups from store
* @return {Array} Group objects * @returns {Group[]} Group objects
*/ */
get groups() { get groups() {
return this.state.groups; return this.state.groups;
@ -115,7 +118,7 @@ export default class Store {
/** /**
* Get active groups from store * Get active groups from store
* @return {Array} Group objects * @returns {Group[]} Group objects
*/ */
get activeGroups() { get activeGroups() {
const { groups, choices } = this; const { groups, choices } = this;
@ -132,7 +135,7 @@ export default class Store {
/** /**
* Get loading state from store * Get loading state from store
* @return {Boolean} Loading State * @returns {boolean} Loading State
*/ */
isLoading() { isLoading() {
return this.state.general.loading; return this.state.general.loading;
@ -140,23 +143,17 @@ export default class Store {
/** /**
* Get single choice by it's ID * Get single choice by it's ID
* @param {id} string * @param {string} id
* @return {import('../../../types/index').Choices.Choice | false} Found choice * @returns {Choice | undefined} Found choice
*/ */
getChoiceById(id) { getChoiceById(id) {
if (id) { return this.activeChoices.find(choice => choice.id === parseInt(id, 10));
const n = parseInt(id, 10);
return this.activeChoices.find(choice => choice.id === n);
}
return false;
} }
/** /**
* Get group by group id * Get group by group id
* @param {Number} id Group ID * @param {string} id Group ID
* @return {Object} Group data * @returns {Group | undefined} Group data
*/ */
getGroupById(id) { getGroupById(id) {
return this.groups.find(group => group.id === parseInt(id, 10)); return this.groups.find(group => group.id === parseInt(id, 10));

View File

@ -218,13 +218,6 @@ describe('reducers/store', () => {
expect(actualResponse).to.eql(expectedResponse); expect(actualResponse).to.eql(expectedResponse);
}); });
}); });
describe('passing no id', () => {
it('returns false', () => {
const actualResponse = instance.getChoiceById();
expect(actualResponse).to.equal(false);
});
});
}); });
describe('placeholderChoice getter', () => { describe('placeholderChoice getter', () => {

75
types/index.d.ts vendored
View File

@ -19,14 +19,16 @@ declare namespace Choices {
type noticeStringFunction = (value: string) => string; type noticeStringFunction = (value: string) => string;
type noticeLimitFunction = (maxItemCount: number) => string; type noticeLimitFunction = (maxItemCount: number) => string;
type filterFunction = (value: string) => boolean; type filterFunction = (value: string) => boolean;
type valueCompareFunction = (value1: string, value2: string) => boolean;
} }
interface Choice { interface Choice {
id?: string;
customProperties?: Record<string, any>; customProperties?: Record<string, any>;
disabled?: boolean; disabled?: boolean;
active?: boolean;
elementId?: string; elementId?: string;
groupId?: string; groupId?: string;
id?: string;
keyCode?: number; keyCode?: number;
label: string; label: string;
placeholder?: boolean; placeholder?: boolean;
@ -34,6 +36,18 @@ declare namespace Choices {
value: string; value: string;
} }
interface Group {
id?: string;
active?: boolean;
disabled?: boolean;
value: any;
}
interface Item extends Choice {
choiceId?: string;
keyCode?: number;
}
/** /**
* Events fired by Choices behave the same as standard events. Each event is triggered on the element passed to Choices (accessible via `this.passedElement`. Arguments are accessible within the `event.detail` object. * Events fired by Choices behave the same as standard events. Each event is triggered on the element passed to Choices (accessible via `this.passedElement`. Arguments are accessible within the `event.detail` object.
*/ */
@ -149,18 +163,6 @@ declare namespace Choices {
highlightChoice: CustomEvent<{ el: Choices.passedElement }>; highlightChoice: CustomEvent<{ el: Choices.passedElement }>;
} }
interface Group {
active?: boolean;
disabled?: boolean;
id?: string;
value: any;
}
interface Item extends Choice {
choiceId?: string;
keyCode?: number;
}
interface Templates { interface Templates {
containerOuter: ( containerOuter: (
this: Choices, this: Choices,
@ -404,7 +406,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
addItemFilter: string | RegExp | Choices.Types.filterFunction; addItemFilter: string | RegExp | Choices.Types.filterFunction | null;
/** /**
* The text that is shown when a user has inputted a new item but has not pressed the enter key. To access the current input value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string. * The text that is shown when a user has inputted a new item but has not pressed the enter key. To access the current input value, pass a function with a `value` argument (see the **default config** [https://github.com/jshjohnson/Choices#setup] for an example), otherwise pass a string.
@ -564,7 +566,7 @@ declare namespace Choices {
* ``` * ```
* // Sorting via length of label from largest to smallest * // Sorting via length of label from largest to smallest
* const example = new Choices(element, { * const example = new Choices(element, {
* sortFilter: function(a, b) { * sorter: function(a, b) {
* return b.label.length - a.label.length; * return b.label.length - a.label.length;
* }, * },
* }; * };
@ -572,7 +574,7 @@ declare namespace Choices {
* *
* @default sortByAlpha * @default sortByAlpha
*/ */
sortFilter: (current: Choice, next: Choice) => number; sorter: (current: Choice, next: Choice) => number;
/** /**
* Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value. * Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value.
@ -600,7 +602,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
placeholderValue: string; placeholderValue: string | null;
/** /**
* The value of the search inputs placeholder. * The value of the search inputs placeholder.
@ -609,7 +611,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
searchPlaceholderValue: string; searchPlaceholderValue: string | null;
/** /**
* Prepend a value to each item added/selected. * Prepend a value to each item added/selected.
@ -618,7 +620,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
prependValue: string; prependValue: string | null;
/** /**
* Append a value to each item added/selected. * Append a value to each item added/selected.
@ -627,7 +629,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
appendValue: string; appendValue: string | null;
/** /**
* Whether selected choices should be removed from the list. By default choices are removed when they are selected in multiple select box. To always render choices pass `always`. * Whether selected choices should be removed from the list. By default choices are removed when they are selected in multiple select box. To always render choices pass `always`.
@ -689,16 +691,37 @@ declare namespace Choices {
/** /**
* If no duplicates are allowed, and the value already exists in the array. * If no duplicates are allowed, and the value already exists in the array.
* *
* @default 'Only unique values can be added.' * @default 'Only unique values can be added'
*/ */
uniqueItemText: string | Choices.Types.noticeStringFunction; uniqueItemText: string | Choices.Types.noticeStringFunction;
/**
* The text that is shown when addItemFilter is passed and it returns false
*
* **Input types affected:** text
*
* @default 'Only values matching specific conditions can be added'
*/
customAddItemText: string | Choices.Types.noticeStringFunction;
/**
* Compare choice and value in appropriate way (e.g. deep equality for objects). To compare choice and value, pass a function with a `valueComparer` argument (see the [default config](https://github.com/jshjohnson/Choices#setup) for an example).
*
* **Input types affected:** select-one, select-multiple
*
* @default
* ```
* (choice, item) => choice === item;
* ```
*/
valueComparer: Choices.Types.valueCompareFunction;
/** /**
* Classes added to HTML generated by Choices. By default classnames follow the BEM notation. * Classes added to HTML generated by Choices. By default classnames follow the BEM notation.
* *
* **Input types affected:** text, select-one, select-multiple * **Input types affected:** text, select-one, select-multiple
*/ */
classNames: Partial<Choices.ClassNames>; classNames: Choices.ClassNames;
/** /**
* Choices uses the great Fuse library for searching. You can find more options here: https://github.com/krisk/Fuse#options * Choices uses the great Fuse library for searching. You can find more options here: https://github.com/krisk/Fuse#options
@ -714,7 +737,7 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
callbackOnInit: (this: Choices) => void; callbackOnInit: ((this: Choices) => void) | null;
/** /**
* Function to run on template creation. Through this callback it is possible to provide custom templates for the various components of Choices (see terminology). For Choices to work with custom templates, it is important you maintain the various data attributes defined here [https://github.com/jshjohnson/Choices/blob/67f29c286aa21d88847adfcd6304dc7d068dc01f/assets/scripts/src/choices.js#L1993-L2067]. * Function to run on template creation. Through this callback it is possible to provide custom templates for the various components of Choices (see terminology). For Choices to work with custom templates, it is important you maintain the various data attributes defined here [https://github.com/jshjohnson/Choices/blob/67f29c286aa21d88847adfcd6304dc7d068dc01f/assets/scripts/src/choices.js#L1993-L2067].
@ -750,9 +773,9 @@ declare namespace Choices {
* *
* @default null * @default null
*/ */
callbackOnCreateTemplates: ( callbackOnCreateTemplates:
template: Choices.Types.strToEl, | ((template: Choices.Types.strToEl) => Partial<Choices.Templates>)
) => Partial<Choices.Templates>; | null;
} }
} }