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? -->
<!--- 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)
## Types of changes
@ -17,6 +11,7 @@
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
- [ ] Chore (tooling change or documentation change)
- [ ] Refactor (non-breaking change which maintains existing functionality)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] 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.
@ -105,7 +105,7 @@ Or include Choices directly:
resetScrollPosition: true,
shouldSort: true,
shouldSortItems: false,
sortFn: () => {...},
sorter: () => {...},
placeholder: true,
placeholderValue: null,
searchPlaceholderValue: null,
@ -122,8 +122,8 @@ Or include Choices directly:
maxItemText: (maxItemCount) => {
return `Only ${maxItemCount} values can be added`;
},
itemComparer: (choice, item) => {
return choice === item;
valueComparer: (value1, value2) => {
return value1 === value2;
},
classNames: {
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.
### sortFn
### sorter
**Type:** `Function` **Default:** sortByAlpha
@ -421,7 +421,7 @@ new Choices(element, {
```js
// Sorting via length of label from largest to smallest
const example = new Choices(element, {
sortFn: function(a, b) {
sorter: function(a, b) {
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.
### itemComparer
### valueComparer
**Type:** `Function` **Default:** `strict equality`
**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

View File

@ -11,6 +11,7 @@
"noUnusedLocals": true,
"noUnusedParameters": 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';
/**
* @argument {Choice} choice
* @returns {{ type: string } & Choice}
*/
export const addChoice = ({
value,
label,
@ -23,16 +31,27 @@ export const addChoice = ({
keyCode,
});
/**
* @argument {Choice[]} results
* @returns {{ type: string, results: Choice[] }}
*/
export const filterChoices = results => ({
type: ACTION_TYPES.FILTER_CHOICES,
results,
});
/**
* @argument {boolean} active
* @returns {{ type: string, active: boolean }}
*/
export const activateChoices = (active = true) => ({
type: ACTION_TYPES.ACTIVATE_CHOICES,
active,
});
/**
* @returns {{ type: string }}
*/
export const clearChoices = () => ({
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';
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,
value,
id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,39 @@
import { dispatchEvent } from '../lib/utils';
export default class WrappedElement {
constructor({ element, classNames }) {
Object.assign(this, { element, classNames });
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @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');
}
this.isDisabled = false;
}
get isActive() {
return this.element.dataset.choice === 'active';
}
get dir() {
return this.element.dir;
}
get value() {
return this.element.value;
}
@ -26,7 +49,7 @@ export default class WrappedElement {
this.element.hidden = true;
// Remove element from tab index
this.element.tabIndex = '-1';
this.element.tabIndex = -1;
// Backup original styles if any
const origStyle = this.element.getAttribute('style');

View File

@ -31,6 +31,19 @@ describe('components/wrappedElement', () => {
it('sets isDisabled flag to 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', () => {
@ -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', () => {
let originalStyling;

View File

@ -1,15 +1,33 @@
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 {
/**
* @param {{
* element: HTMLInputElement,
* classNames: ClassNames,
* delimiter: string
* }} args
*/
constructor({ element, classNames, delimiter }) {
super({ element, classNames });
this.delimiter = delimiter;
}
/**
* @returns {string}
*/
get value() {
return this.element.value;
}
/**
* @param {Item[]} items
*/
set value(items) {
const itemValues = items.map(({ value }) => value);
const joinedValues = itemValues.join(this.delimiter);

View File

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

View File

@ -1,5 +1,11 @@
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 = {
containerOuter: 'choices',
containerInner: 'choices__inner',
@ -28,6 +34,7 @@ export const DEFAULT_CLASSNAMES = {
noChoices: 'has-no-choices',
};
/** @type {Options} */
export const DEFAULT_CONFIG = {
items: [],
choices: [],
@ -51,7 +58,7 @@ export const DEFAULT_CONFIG = {
resetScrollPosition: true,
shouldSort: true,
shouldSortItems: false,
sortFn: sortByAlpha,
sorter: sortByAlpha,
placeholder: true,
placeholderValue: null,
searchPlaceholderValue: null,
@ -66,7 +73,7 @@ export const DEFAULT_CONFIG = {
customAddItemText: 'Only values matching specific conditions can be added',
addItemText: value => `Press Enter to add <b>"${sanitise(value)}"</b>`,
maxItemText: maxItemCount => `Only ${maxItemCount} values can be added`,
itemComparer: (choice, item) => choice === item,
valueComparer: (value1, value2) => value1 === value2,
fuseOptions: {
includeScore: true,
},
@ -111,4 +118,8 @@ export const KEY_CODES = {
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;

View File

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

View File

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

View File

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

75
types/index.d.ts vendored
View File

@ -19,14 +19,16 @@ declare namespace Choices {
type noticeStringFunction = (value: string) => string;
type noticeLimitFunction = (maxItemCount: number) => string;
type filterFunction = (value: string) => boolean;
type valueCompareFunction = (value1: string, value2: string) => boolean;
}
interface Choice {
id?: string;
customProperties?: Record<string, any>;
disabled?: boolean;
active?: boolean;
elementId?: string;
groupId?: string;
id?: string;
keyCode?: number;
label: string;
placeholder?: boolean;
@ -34,6 +36,18 @@ declare namespace Choices {
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.
*/
@ -149,18 +163,6 @@ declare namespace Choices {
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 {
containerOuter: (
this: Choices,
@ -404,7 +406,7 @@ declare namespace Choices {
*
* @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.
@ -564,7 +566,7 @@ declare namespace Choices {
* ```
* // Sorting via length of label from largest to smallest
* const example = new Choices(element, {
* sortFilter: function(a, b) {
* sorter: function(a, b) {
* return b.label.length - a.label.length;
* },
* };
@ -572,7 +574,7 @@ declare namespace Choices {
*
* @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.
@ -600,7 +602,7 @@ declare namespace Choices {
*
* @default null
*/
placeholderValue: string;
placeholderValue: string | null;
/**
* The value of the search inputs placeholder.
@ -609,7 +611,7 @@ declare namespace Choices {
*
* @default null
*/
searchPlaceholderValue: string;
searchPlaceholderValue: string | null;
/**
* Prepend a value to each item added/selected.
@ -618,7 +620,7 @@ declare namespace Choices {
*
* @default null
*/
prependValue: string;
prependValue: string | null;
/**
* Append a value to each item added/selected.
@ -627,7 +629,7 @@ declare namespace Choices {
*
* @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`.
@ -689,16 +691,37 @@ declare namespace Choices {
/**
* 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;
/**
* 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.
*
* **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
@ -714,7 +737,7 @@ declare namespace Choices {
*
* @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].
@ -750,9 +773,9 @@ declare namespace Choices {
*
* @default null
*/
callbackOnCreateTemplates: (
template: Choices.Types.strToEl,
) => Partial<Choices.Templates>;
callbackOnCreateTemplates:
| ((template: Choices.Types.strToEl) => Partial<Choices.Templates>)
| null;
}
}