Browse Source

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
pull/737/head
Josh Johnson 2 years ago
committed by GitHub
parent
commit
e6882f3e4b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .github/PULL_REQUEST_TEMPLATE.md
  2. 24
      README.md
  3. 3
      jsconfig.json
  4. 19
      src/scripts/actions/choices.js
  5. 4
      src/scripts/actions/general.js
  6. 28
      src/scripts/actions/general.test.js
  7. 10
      src/scripts/actions/groups.js
  8. 2
      src/scripts/actions/groups.test.js
  9. 18
      src/scripts/actions/items.js
  10. 4
      src/scripts/actions/items.test.js
  11. 16
      src/scripts/actions/misc.js
  12. 36
      src/scripts/actions/misc.test.js
  13. 95
      src/scripts/choices.js
  14. 10
      src/scripts/choices.test.js
  15. 68
      src/scripts/components/container.js
  16. 28
      src/scripts/components/dropdown.js
  17. 34
      src/scripts/components/input.js
  18. 67
      src/scripts/components/list.js
  19. 29
      src/scripts/components/wrapped-element.js
  20. 29
      src/scripts/components/wrapped-element.test.js
  21. 18
      src/scripts/components/wrapped-input.js
  22. 26
      src/scripts/components/wrapped-select.js
  23. 15
      src/scripts/constants.js
  24. 30
      src/scripts/lib/utils.js
  25. 51
      src/scripts/store/store.js
  26. 7
      src/scripts/store/store.test.js
  27. 75
      types/index.d.ts

7
.github/PULL_REQUEST_TEMPLATE.md

@ -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)

24
README.md

@ -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

3
jsconfig.json

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

19
src/scripts/actions/choices.js

@ -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,
});

4
src/scripts/actions/general.js

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

28
src/scripts/actions/general.test.js

@ -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);
});
});
});
});

10
src/scripts/actions/groups.js

@ -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,

2
src/scripts/actions/groups.test.js

@ -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,
);
});

18
src/scripts/actions/items.js

@ -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,

4
src/scripts/actions/items.test.js

@ -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 = {

16
src/scripts/actions/misc.js

@ -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,
});

36
src/scripts/actions/misc.test.js

@ -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);
});
});
});
});

95
src/scripts/choices.js

@ -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,15 +43,17 @@ import {
diff,
} from './lib/utils';
const USER_DEFAULTS = /** @type {Partial<import('../../types/index').Choices.Options>} */ ({});
/**
* Choices
* @author Josh Johnson<josh@joshuajohnson.co.uk>
* @typedef {import('../../types/index').Choices.Choice} Choice
* @typedef {import('../../types/index').Choices.Options} Options
*/
/** @type {Partial<Options>} */
const USER_DEFAULTS = {};
/**
* @typedef {import('../../types/index').Choices.Choice} Choice
* Choices
* @author Josh Johnson<josh@joshuajohnson.co.uk>
*/
class Choices {
static get defaults() {
@ -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;

10
src/scripts/choices.test.js

@ -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', () => {

68
src/scripts/components/container.js

@ -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;
}

28
src/scripts/components/dropdown.js

@ -1,13 +1,26 @@
/**
* @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 }) {
Object.assign(this, { 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);

34
src/scripts/components/input.js

@ -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();

67
src/scripts/components/list.js

@ -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);
});
}
}

29
src/scripts/components/wrapped-element.js

@ -1,16 +1,39 @@
import { dispatchEvent } from '../lib/utils';
/**
* @typedef {import('../../../types/index').Choices.passedElement} passedElement
* @typedef {import('../../../types/index').Choices.ClassNames} ClassNames
*/
export default class WrappedElement {
/**
* @param {{
* element: HTMLInputElement | HTMLSelectElement,
* classNames: ClassNames,
* }} args
*/
constructor({ element, classNames }) {
Object.assign(this, { element, classNames });
this.element = element;
this.classNames = classNames;
if (!(element instanceof Element)) {
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');

29
src/scripts/components/wrapped-element.test.js

@ -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;

18
src/scripts/components/wrapped-input.js

@ -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);

26
src/scripts/components/wrapped-select.js

@ -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);

15
src/scripts/constants.js

@ -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;

30
src/scripts/lib/utils.js

@ -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) => {

51
src/scripts/store/store.js

@ -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);