mirror of
https://github.com/Choices-js/Choices.git
synced 2024-06-16 12:45:10 +02:00
2403 lines
66 KiB
TypeScript
2403 lines
66 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import Fuse from 'fuse.js';
|
|
import merge from 'deepmerge';
|
|
|
|
import Store from './store/store';
|
|
import {
|
|
Dropdown,
|
|
Container,
|
|
Input,
|
|
List,
|
|
WrappedInput,
|
|
WrappedSelect,
|
|
} from './components';
|
|
import {
|
|
DEFAULT_CONFIG,
|
|
EVENTS,
|
|
KEY_CODES,
|
|
TEXT_TYPE,
|
|
SELECT_ONE_TYPE,
|
|
SELECT_MULTIPLE_TYPE,
|
|
} from './constants';
|
|
import templates from './templates';
|
|
import {
|
|
addChoice,
|
|
filterChoices,
|
|
activateChoices,
|
|
clearChoices,
|
|
Result,
|
|
} from './actions/choices';
|
|
import { addItem, removeItem, highlightItem } from './actions/items';
|
|
import { addGroup } from './actions/groups';
|
|
import { clearAll, resetTo, setIsLoading } from './actions/misc';
|
|
import {
|
|
isScrolledIntoView,
|
|
getAdjacentEl,
|
|
getType,
|
|
isType,
|
|
strToEl,
|
|
sortByScore,
|
|
generateId,
|
|
existsInArray,
|
|
diff,
|
|
} from './lib/utils';
|
|
import {
|
|
Options,
|
|
Choice,
|
|
Item,
|
|
Group,
|
|
Notice,
|
|
State,
|
|
PassedElement,
|
|
} from './interfaces';
|
|
import { defaultState } from './reducers';
|
|
|
|
/** @see {@link http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c} */
|
|
const IS_IE11 =
|
|
'-ms-scroll-limit' in document.documentElement.style &&
|
|
'-ms-ime-align' in document.documentElement.style;
|
|
|
|
const USER_DEFAULTS: Partial<Options> = {};
|
|
|
|
/**
|
|
* Choices
|
|
* @author Josh Johnson<josh@joshuajohnson.co.uk>
|
|
*/
|
|
class Choices {
|
|
static get defaults(): {
|
|
options: Partial<Options>;
|
|
templates: typeof templates;
|
|
} {
|
|
return Object.preventExtensions({
|
|
get options(): Partial<Options> {
|
|
return USER_DEFAULTS;
|
|
},
|
|
get templates(): typeof templates {
|
|
return templates;
|
|
},
|
|
});
|
|
}
|
|
|
|
initialised: boolean;
|
|
config: Options;
|
|
passedElement: WrappedInput | WrappedSelect;
|
|
containerOuter: Container;
|
|
containerInner: Container;
|
|
choiceList: List;
|
|
itemList: List;
|
|
input: Input;
|
|
dropdown: Dropdown;
|
|
|
|
_isTextElement: boolean;
|
|
_isSelectOneElement: boolean;
|
|
_isSelectMultipleElement: boolean;
|
|
_isSelectElement: boolean;
|
|
_store: Store;
|
|
_templates: typeof templates;
|
|
_initialState: State;
|
|
_currentState: State;
|
|
_prevState: State;
|
|
_currentValue: string;
|
|
_canSearch: boolean;
|
|
_isScrollingOnIe: boolean;
|
|
_highlightPosition: number;
|
|
_wasTap: boolean;
|
|
_isSearching: boolean;
|
|
_placeholderValue: string | null;
|
|
_baseId: string;
|
|
_direction: HTMLElement['dir'];
|
|
_idNames: {
|
|
itemChoice: string;
|
|
};
|
|
_presetGroups: Group[] | HTMLOptGroupElement[] | Element[];
|
|
_presetOptions: Item[] | HTMLOptionElement[];
|
|
_presetChoices: Partial<Choice>[];
|
|
_presetItems: Item[] | string[];
|
|
|
|
constructor(
|
|
element:
|
|
| string
|
|
| Element
|
|
| HTMLInputElement
|
|
| HTMLSelectElement = '[data-choice]',
|
|
userConfig: Partial<Options> = {},
|
|
) {
|
|
this.config = merge.all<Options>(
|
|
[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: (_, sourceArray) => [...sourceArray] },
|
|
);
|
|
|
|
const invalidConfigOptions = diff(this.config, DEFAULT_CONFIG);
|
|
if (invalidConfigOptions.length) {
|
|
console.warn(
|
|
'Unknown config option(s) passed',
|
|
invalidConfigOptions.join(', '),
|
|
);
|
|
}
|
|
|
|
const passedElement =
|
|
typeof element === 'string' ? document.querySelector(element) : element;
|
|
|
|
if (
|
|
!(
|
|
passedElement instanceof HTMLInputElement ||
|
|
passedElement instanceof HTMLSelectElement
|
|
)
|
|
) {
|
|
throw TypeError(
|
|
'Expected one of the following types text|select-one|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;
|
|
|
|
this.config.searchEnabled =
|
|
this._isSelectMultipleElement || this.config.searchEnabled;
|
|
|
|
if (!['auto', 'always'].includes(`${this.config.renderSelectedChoices}`)) {
|
|
this.config.renderSelectedChoices = 'auto';
|
|
}
|
|
|
|
if (
|
|
userConfig.addItemFilter &&
|
|
typeof userConfig.addItemFilter !== 'function'
|
|
) {
|
|
const re =
|
|
userConfig.addItemFilter instanceof RegExp
|
|
? userConfig.addItemFilter
|
|
: new RegExp(userConfig.addItemFilter);
|
|
|
|
this.config.addItemFilter = re.test.bind(re);
|
|
}
|
|
|
|
if (this._isTextElement) {
|
|
this.passedElement = new WrappedInput({
|
|
element: passedElement as HTMLInputElement,
|
|
classNames: this.config.classNames,
|
|
delimiter: this.config.delimiter,
|
|
});
|
|
} else {
|
|
this.passedElement = new WrappedSelect({
|
|
element: passedElement as HTMLSelectElement,
|
|
classNames: this.config.classNames,
|
|
template: (data: Item): HTMLOptionElement =>
|
|
this._templates.option(data),
|
|
});
|
|
}
|
|
|
|
this.initialised = false;
|
|
|
|
this._store = new Store();
|
|
this._initialState = defaultState;
|
|
this._currentState = defaultState;
|
|
this._prevState = defaultState;
|
|
this._currentValue = '';
|
|
this._canSearch = !!this.config.searchEnabled;
|
|
this._isScrollingOnIe = false;
|
|
this._highlightPosition = 0;
|
|
this._wasTap = true;
|
|
this._placeholderValue = this._generatePlaceholderValue();
|
|
this._baseId = generateId(this.passedElement.element, 'choices-');
|
|
|
|
/**
|
|
* setting direction in cases where it's explicitly set on passedElement
|
|
* or when calculated direction is different from the document
|
|
*/
|
|
this._direction = this.passedElement.dir;
|
|
|
|
if (!this._direction) {
|
|
const { direction: elementDirection } = window.getComputedStyle(
|
|
this.passedElement.element,
|
|
);
|
|
const { direction: documentDirection } = window.getComputedStyle(
|
|
document.documentElement,
|
|
);
|
|
if (elementDirection !== documentDirection) {
|
|
this._direction = elementDirection;
|
|
}
|
|
}
|
|
|
|
this._idNames = {
|
|
itemChoice: 'item-choice',
|
|
};
|
|
|
|
if (this._isSelectElement) {
|
|
// Assign preset groups from passed element
|
|
this._presetGroups = (this.passedElement as WrappedSelect).optionGroups;
|
|
// Assign preset options from passed element
|
|
this._presetOptions = (this.passedElement as WrappedSelect).options;
|
|
}
|
|
|
|
// Assign preset choices from passed object
|
|
this._presetChoices = this.config.choices;
|
|
// Assign preset items from passed object first
|
|
this._presetItems = this.config.items;
|
|
// Add any values passed from attribute
|
|
if (this.passedElement.value && this._isTextElement) {
|
|
const splitValues: string[] = this.passedElement.value.split(
|
|
this.config.delimiter,
|
|
);
|
|
this._presetItems = (this._presetItems as string[]).concat(splitValues);
|
|
}
|
|
// Create array of choices from option elements
|
|
if ((this.passedElement as WrappedSelect).options) {
|
|
(this.passedElement as WrappedSelect).options.forEach(option => {
|
|
this._presetChoices.push({
|
|
value: option.value,
|
|
label: option.innerHTML,
|
|
selected: !!option.selected,
|
|
disabled: option.disabled || option.parentNode.disabled,
|
|
placeholder:
|
|
option.value === '' || option.hasAttribute('placeholder'),
|
|
customProperties: option.dataset['custom-properties'],
|
|
});
|
|
});
|
|
}
|
|
|
|
this._render = this._render.bind(this);
|
|
this._onFocus = this._onFocus.bind(this);
|
|
this._onBlur = this._onBlur.bind(this);
|
|
this._onKeyUp = this._onKeyUp.bind(this);
|
|
this._onKeyDown = this._onKeyDown.bind(this);
|
|
this._onClick = this._onClick.bind(this);
|
|
this._onTouchMove = this._onTouchMove.bind(this);
|
|
this._onTouchEnd = this._onTouchEnd.bind(this);
|
|
this._onMouseDown = this._onMouseDown.bind(this);
|
|
this._onMouseOver = this._onMouseOver.bind(this);
|
|
this._onFormReset = this._onFormReset.bind(this);
|
|
this._onSelectKey = this._onSelectKey.bind(this);
|
|
this._onEnterKey = this._onEnterKey.bind(this);
|
|
this._onEscapeKey = this._onEscapeKey.bind(this);
|
|
this._onDirectionKey = this._onDirectionKey.bind(this);
|
|
this._onDeleteKey = this._onDeleteKey.bind(this);
|
|
|
|
// If element has already been initialised with Choices, fail silently
|
|
if (this.passedElement.isActive) {
|
|
if (!this.config.silent) {
|
|
console.warn(
|
|
'Trying to initialise Choices on element already initialised',
|
|
{ element },
|
|
);
|
|
}
|
|
|
|
this.initialised = true;
|
|
|
|
return;
|
|
}
|
|
|
|
// Let's go
|
|
this.init();
|
|
}
|
|
|
|
init(): void {
|
|
if (this.initialised) {
|
|
return;
|
|
}
|
|
|
|
this._createTemplates();
|
|
this._createElements();
|
|
this._createStructure();
|
|
|
|
this._store.subscribe(this._render);
|
|
|
|
this._render();
|
|
this._addEventListeners();
|
|
|
|
const shouldDisable =
|
|
!this.config.addItems ||
|
|
this.passedElement.element.hasAttribute('disabled');
|
|
|
|
if (shouldDisable) {
|
|
this.disable();
|
|
}
|
|
|
|
this.initialised = true;
|
|
|
|
const { callbackOnInit } = this.config;
|
|
// Run callback if it is a function
|
|
if (callbackOnInit && typeof callbackOnInit === 'function') {
|
|
callbackOnInit.call(this);
|
|
}
|
|
}
|
|
|
|
destroy(): void {
|
|
if (!this.initialised) {
|
|
return;
|
|
}
|
|
|
|
this._removeEventListeners();
|
|
this.passedElement.reveal();
|
|
this.containerOuter.unwrap(this.passedElement.element);
|
|
|
|
this.clearStore();
|
|
|
|
if (this._isSelectElement) {
|
|
(this.passedElement as WrappedSelect).options = this._presetOptions;
|
|
}
|
|
|
|
this._templates = templates;
|
|
this.initialised = false;
|
|
}
|
|
|
|
enable(): this {
|
|
if (this.passedElement.isDisabled) {
|
|
this.passedElement.enable();
|
|
}
|
|
|
|
if (this.containerOuter.isDisabled) {
|
|
this._addEventListeners();
|
|
this.input.enable();
|
|
this.containerOuter.enable();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
disable(): this {
|
|
if (!this.passedElement.isDisabled) {
|
|
this.passedElement.disable();
|
|
}
|
|
|
|
if (!this.containerOuter.isDisabled) {
|
|
this._removeEventListeners();
|
|
this.input.disable();
|
|
this.containerOuter.disable();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
highlightItem(item: Item, runEvent = true): this {
|
|
if (!item || !item.id) {
|
|
return this;
|
|
}
|
|
|
|
const { id, groupId = -1, value = '', label = '' } = item;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
this._store.dispatch(highlightItem(id, true));
|
|
|
|
if (runEvent) {
|
|
this.passedElement.triggerEvent(EVENTS.highlightItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
groupValue: group && group.value ? group.value : null,
|
|
});
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
unhighlightItem(item: Item): this {
|
|
if (!item || !item.id) {
|
|
return this;
|
|
}
|
|
|
|
const { id, groupId = -1, value = '', label = '' } = item;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
this._store.dispatch(highlightItem(id, false));
|
|
this.passedElement.triggerEvent(EVENTS.highlightItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
groupValue: group && group.value ? group.value : null,
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
highlightAll(): this {
|
|
this._store.items.forEach(item => this.highlightItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
unhighlightAll(): this {
|
|
this._store.items.forEach(item => this.unhighlightItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
removeActiveItemsByValue(value: string): this {
|
|
this._store.activeItems
|
|
.filter(item => item.value === value)
|
|
.forEach(item => this._removeItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
removeActiveItems(excludedId: number): this {
|
|
this._store.activeItems
|
|
.filter(({ id }) => id !== excludedId)
|
|
.forEach(item => this._removeItem(item));
|
|
|
|
return this;
|
|
}
|
|
|
|
removeHighlightedItems(runEvent = false): this {
|
|
this._store.highlightedActiveItems.forEach(item => {
|
|
this._removeItem(item);
|
|
// If this action was performed by the user
|
|
// trigger the event
|
|
if (runEvent) {
|
|
this._triggerChange(item.value);
|
|
}
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
showDropdown(preventInputFocus?: boolean): this {
|
|
if (this.dropdown.isActive) {
|
|
return this;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
this.dropdown.show();
|
|
this.containerOuter.open(this.dropdown.distanceFromTopWindow);
|
|
|
|
if (!preventInputFocus && this._canSearch) {
|
|
this.input.focus();
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.showDropdown, {});
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
hideDropdown(preventInputBlur?: boolean): this {
|
|
if (!this.dropdown.isActive) {
|
|
return this;
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
this.dropdown.hide();
|
|
this.containerOuter.close();
|
|
|
|
if (!preventInputBlur && this._canSearch) {
|
|
this.input.removeActiveDescendant();
|
|
this.input.blur();
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.hideDropdown, {});
|
|
});
|
|
|
|
return this;
|
|
}
|
|
|
|
getValue(valueOnly = false): string[] | Item[] | Item | string {
|
|
const values = this._store.activeItems.reduce<any[]>(
|
|
(selectedItems, item) => {
|
|
const itemValue = valueOnly ? item.value : item;
|
|
selectedItems.push(itemValue);
|
|
|
|
return selectedItems;
|
|
},
|
|
[],
|
|
);
|
|
|
|
return this._isSelectOneElement ? values[0] : values;
|
|
}
|
|
|
|
setValue(items: string[] | Item[]): this {
|
|
if (!this.initialised) {
|
|
return this;
|
|
}
|
|
|
|
items.forEach(value => this._setChoiceOrItem(value));
|
|
|
|
return this;
|
|
}
|
|
|
|
setChoiceByValue(value: string): this {
|
|
if (!this.initialised || this._isTextElement) {
|
|
return this;
|
|
}
|
|
|
|
// If only one value has been passed, convert to array
|
|
const choiceValue = Array.isArray(value) ? value : [value];
|
|
|
|
// Loop through each value and
|
|
choiceValue.forEach(val => this._findAndSelectChoiceByValue(val));
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set choices of select input via an array of objects (or function that returns array of object or promise of it),
|
|
* a value field name and a label field name.
|
|
* This behaves the same as passing items via the choices option but can be called after initialising Choices.
|
|
* This can also be used to add groups of choices (see example 2); Optionally pass a true `replaceChoices` value to remove any existing choices.
|
|
* Optionally pass a `customProperties` object to add additional data to your choices (useful when searching/filtering etc).
|
|
*
|
|
* **Input types affected:** select-one, select-multiple
|
|
*
|
|
* @example
|
|
* ```js
|
|
* const example = new Choices(element);
|
|
*
|
|
* example.setChoices([
|
|
* {value: 'One', label: 'Label One', disabled: true},
|
|
* {value: 'Two', label: 'Label Two', selected: true},
|
|
* {value: 'Three', label: 'Label Three'},
|
|
* ], 'value', 'label', false);
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```js
|
|
* const example = new Choices(element);
|
|
*
|
|
* example.setChoices(async () => {
|
|
* try {
|
|
* const items = await fetch('/items');
|
|
* return items.json()
|
|
* } catch(err) {
|
|
* console.error(err)
|
|
* }
|
|
* });
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```js
|
|
* const example = new Choices(element);
|
|
*
|
|
* example.setChoices([{
|
|
* label: 'Group one',
|
|
* id: 1,
|
|
* disabled: false,
|
|
* choices: [
|
|
* {value: 'Child One', label: 'Child One', selected: true},
|
|
* {value: 'Child Two', label: 'Child Two', disabled: true},
|
|
* {value: 'Child Three', label: 'Child Three'},
|
|
* ]
|
|
* },
|
|
* {
|
|
* label: 'Group two',
|
|
* id: 2,
|
|
* disabled: false,
|
|
* choices: [
|
|
* {value: 'Child Four', label: 'Child Four', disabled: true},
|
|
* {value: 'Child Five', label: 'Child Five'},
|
|
* {value: 'Child Six', label: 'Child Six', customProperties: {
|
|
* description: 'Custom description about child six',
|
|
* random: 'Another random custom property'
|
|
* }},
|
|
* ]
|
|
* }], 'value', 'label', false);
|
|
* ```
|
|
*/
|
|
setChoices(
|
|
choicesArrayOrFetcher:
|
|
| Choice[]
|
|
| Group[]
|
|
| ((instance: Choices) => Choice[] | Promise<Choice[]>) = [],
|
|
value = 'value',
|
|
label = 'label',
|
|
replaceChoices = false,
|
|
): this | Promise<this> {
|
|
if (!this.initialised) {
|
|
throw new ReferenceError(
|
|
`setChoices was called on a non-initialized instance of Choices`,
|
|
);
|
|
}
|
|
if (!this._isSelectElement) {
|
|
throw new TypeError(`setChoices can't be used with INPUT based Choices`);
|
|
}
|
|
|
|
if (typeof value !== 'string' || !value) {
|
|
throw new TypeError(
|
|
`value parameter must be a name of 'value' field in passed objects`,
|
|
);
|
|
}
|
|
|
|
// Clear choices if needed
|
|
if (replaceChoices) {
|
|
this.clearChoices();
|
|
}
|
|
|
|
if (typeof choicesArrayOrFetcher === 'function') {
|
|
// it's a choices fetcher function
|
|
const fetcher = choicesArrayOrFetcher(this);
|
|
|
|
if (typeof Promise === 'function' && fetcher instanceof Promise) {
|
|
// that's a promise
|
|
// eslint-disable-next-line compat/compat
|
|
return new Promise(resolve => requestAnimationFrame(resolve)) // eslint-disable-line compat/compat
|
|
.then(() => this._handleLoadingState(true))
|
|
.then(() => fetcher)
|
|
.then((data: Choice[]) =>
|
|
this.setChoices(data, value, label, replaceChoices),
|
|
)
|
|
.catch(err => {
|
|
if (!this.config.silent) {
|
|
console.error(err);
|
|
}
|
|
})
|
|
.then(() => this._handleLoadingState(false))
|
|
.then(() => this);
|
|
}
|
|
|
|
// function returned something else than promise, let's check if it's an array of choices
|
|
if (!Array.isArray(fetcher)) {
|
|
throw new TypeError(
|
|
`.setChoices first argument function must return either array of choices or Promise, got: ${typeof fetcher}`,
|
|
);
|
|
}
|
|
|
|
// recursion with results, it's sync and choices were cleared already
|
|
return this.setChoices(fetcher, value, label, false);
|
|
}
|
|
|
|
if (!Array.isArray(choicesArrayOrFetcher)) {
|
|
throw new TypeError(
|
|
`.setChoices must be called either with array of choices with a function resulting into Promise of array of choices`,
|
|
);
|
|
}
|
|
|
|
this.containerOuter.removeLoadingState();
|
|
|
|
this._startLoading();
|
|
|
|
type ChoiceGroup = {
|
|
id: string;
|
|
choices: Choice[];
|
|
};
|
|
|
|
choicesArrayOrFetcher.forEach((groupOrChoice: ChoiceGroup | Choice) => {
|
|
if ((groupOrChoice as ChoiceGroup).choices) {
|
|
this._addGroup({
|
|
id: groupOrChoice.id ? parseInt(`${groupOrChoice.id}`, 10) : null,
|
|
group: groupOrChoice,
|
|
valueKey: value,
|
|
labelKey: label,
|
|
});
|
|
} else {
|
|
const choice = groupOrChoice as Choice;
|
|
this._addChoice({
|
|
value: choice[value],
|
|
label: choice[label],
|
|
isSelected: !!choice.selected,
|
|
isDisabled: !!choice.disabled,
|
|
placeholder: !!choice.placeholder,
|
|
customProperties: choice.customProperties,
|
|
});
|
|
}
|
|
});
|
|
|
|
this._stopLoading();
|
|
|
|
return this;
|
|
}
|
|
|
|
clearChoices(): this {
|
|
this._store.dispatch(clearChoices());
|
|
|
|
return this;
|
|
}
|
|
|
|
clearStore(): this {
|
|
this._store.dispatch(clearAll());
|
|
|
|
return this;
|
|
}
|
|
|
|
clearInput(): this {
|
|
const shouldSetInputWidth = !this._isSelectOneElement;
|
|
this.input.clear(shouldSetInputWidth);
|
|
|
|
if (!this._isTextElement && this._canSearch) {
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
_render(): void {
|
|
if (this._store.isLoading()) {
|
|
return;
|
|
}
|
|
|
|
this._currentState = this._store.state;
|
|
|
|
const stateChanged =
|
|
this._currentState.choices !== this._prevState.choices ||
|
|
this._currentState.groups !== this._prevState.groups ||
|
|
this._currentState.items !== this._prevState.items;
|
|
const shouldRenderChoices = this._isSelectElement;
|
|
const shouldRenderItems =
|
|
this._currentState.items !== this._prevState.items;
|
|
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
|
|
if (shouldRenderChoices) {
|
|
this._renderChoices();
|
|
}
|
|
|
|
if (shouldRenderItems) {
|
|
this._renderItems();
|
|
}
|
|
|
|
this._prevState = this._currentState;
|
|
}
|
|
|
|
_renderChoices(): void {
|
|
const { activeGroups, activeChoices } = this._store;
|
|
let choiceListFragment = document.createDocumentFragment();
|
|
|
|
this.choiceList.clear();
|
|
|
|
if (this.config.resetScrollPosition) {
|
|
requestAnimationFrame(() => this.choiceList.scrollToTop());
|
|
}
|
|
|
|
// If we have grouped options
|
|
if (activeGroups.length >= 1 && !this._isSearching) {
|
|
// If we have a placeholder choice along with groups
|
|
const activePlaceholders = activeChoices.filter(
|
|
activeChoice =>
|
|
activeChoice.placeholder === true && activeChoice.groupId === -1,
|
|
);
|
|
if (activePlaceholders.length >= 1) {
|
|
choiceListFragment = this._createChoicesFragment(
|
|
activePlaceholders,
|
|
choiceListFragment,
|
|
);
|
|
}
|
|
choiceListFragment = this._createGroupsFragment(
|
|
activeGroups,
|
|
activeChoices,
|
|
choiceListFragment,
|
|
);
|
|
} else if (activeChoices.length >= 1) {
|
|
choiceListFragment = this._createChoicesFragment(
|
|
activeChoices,
|
|
choiceListFragment,
|
|
);
|
|
}
|
|
|
|
// If we have choices to show
|
|
if (
|
|
choiceListFragment.childNodes &&
|
|
choiceListFragment.childNodes.length > 0
|
|
) {
|
|
const { activeItems } = this._store;
|
|
const canAddItem = this._canAddItem(activeItems, this.input.value);
|
|
|
|
// ...and we can select them
|
|
if (canAddItem.response) {
|
|
// ...append them and highlight the first choice
|
|
this.choiceList.append(choiceListFragment);
|
|
this._highlightChoice();
|
|
} else {
|
|
const notice = this._getTemplate('notice', canAddItem.notice);
|
|
this.choiceList.append(notice);
|
|
}
|
|
} else {
|
|
// Otherwise show a notice
|
|
let dropdownItem;
|
|
let notice;
|
|
|
|
if (this._isSearching) {
|
|
notice =
|
|
typeof this.config.noResultsText === 'function'
|
|
? this.config.noResultsText()
|
|
: this.config.noResultsText;
|
|
|
|
dropdownItem = this._getTemplate('notice', notice, 'no-results');
|
|
} else {
|
|
notice =
|
|
typeof this.config.noChoicesText === 'function'
|
|
? this.config.noChoicesText()
|
|
: this.config.noChoicesText;
|
|
|
|
dropdownItem = this._getTemplate('notice', notice, 'no-choices');
|
|
}
|
|
|
|
this.choiceList.append(dropdownItem);
|
|
}
|
|
}
|
|
|
|
_renderItems(): void {
|
|
const activeItems = this._store.activeItems || [];
|
|
this.itemList.clear();
|
|
|
|
// Create a fragment to store our list items
|
|
// (so we don't have to update the DOM for each item)
|
|
const itemListFragment = this._createItemsFragment(activeItems);
|
|
|
|
// If we have items to add, append them
|
|
if (itemListFragment.childNodes) {
|
|
this.itemList.append(itemListFragment);
|
|
}
|
|
}
|
|
|
|
_createGroupsFragment(
|
|
groups: Group[],
|
|
choices: Choice[],
|
|
fragment: DocumentFragment = document.createDocumentFragment(),
|
|
): DocumentFragment {
|
|
const getGroupChoices = (group): Choice[] =>
|
|
choices.filter(choice => {
|
|
if (this._isSelectOneElement) {
|
|
return choice.groupId === group.id;
|
|
}
|
|
|
|
return (
|
|
choice.groupId === group.id &&
|
|
(this.config.renderSelectedChoices === 'always' || !choice.selected)
|
|
);
|
|
});
|
|
|
|
// If sorting is enabled, filter groups
|
|
if (this.config.shouldSort) {
|
|
groups.sort(this.config.sorter);
|
|
}
|
|
|
|
groups.forEach(group => {
|
|
const groupChoices = getGroupChoices(group);
|
|
if (groupChoices.length >= 1) {
|
|
const dropdownGroup = this._getTemplate('choiceGroup', group);
|
|
fragment.appendChild(dropdownGroup);
|
|
this._createChoicesFragment(groupChoices, fragment, true);
|
|
}
|
|
});
|
|
|
|
return fragment;
|
|
}
|
|
|
|
_createChoicesFragment(
|
|
choices: Choice[],
|
|
fragment: DocumentFragment = document.createDocumentFragment(),
|
|
withinGroup = false,
|
|
): DocumentFragment {
|
|
// Create a fragment to store our list items (so we don't have to update the DOM for each item)
|
|
const {
|
|
renderSelectedChoices,
|
|
searchResultLimit,
|
|
renderChoiceLimit,
|
|
} = this.config;
|
|
const filter = this._isSearching ? sortByScore : this.config.sorter;
|
|
const appendChoice = (choice: Choice): void => {
|
|
const shouldRender =
|
|
renderSelectedChoices === 'auto'
|
|
? this._isSelectOneElement || !choice.selected
|
|
: true;
|
|
|
|
if (shouldRender) {
|
|
const dropdownItem = this._getTemplate(
|
|
'choice',
|
|
choice,
|
|
this.config.itemSelectText,
|
|
);
|
|
|
|
fragment.appendChild(dropdownItem);
|
|
}
|
|
};
|
|
|
|
let rendererableChoices = choices;
|
|
|
|
if (renderSelectedChoices === 'auto' && !this._isSelectOneElement) {
|
|
rendererableChoices = choices.filter(choice => !choice.selected);
|
|
}
|
|
|
|
// Split array into placeholders and "normal" choices
|
|
const { placeholderChoices, normalChoices } = rendererableChoices.reduce(
|
|
(acc, choice: Choice) => {
|
|
if (choice.placeholder) {
|
|
acc.placeholderChoices.push(choice);
|
|
} else {
|
|
acc.normalChoices.push(choice);
|
|
}
|
|
|
|
return acc;
|
|
},
|
|
{
|
|
placeholderChoices: [] as Choice[],
|
|
normalChoices: [] as Choice[],
|
|
},
|
|
);
|
|
|
|
// If sorting is enabled or the user is searching, filter choices
|
|
if (this.config.shouldSort || this._isSearching) {
|
|
normalChoices.sort(filter);
|
|
}
|
|
|
|
let choiceLimit = rendererableChoices.length;
|
|
|
|
// Prepend placeholeder
|
|
const sortedChoices = this._isSelectOneElement
|
|
? [...placeholderChoices, ...normalChoices]
|
|
: normalChoices;
|
|
|
|
if (this._isSearching) {
|
|
choiceLimit = searchResultLimit;
|
|
} else if (renderChoiceLimit && renderChoiceLimit > 0 && !withinGroup) {
|
|
choiceLimit = renderChoiceLimit;
|
|
}
|
|
|
|
// Add each choice to dropdown within range
|
|
for (let i = 0; i < choiceLimit; i += 1) {
|
|
if (sortedChoices[i]) {
|
|
appendChoice(sortedChoices[i]);
|
|
}
|
|
}
|
|
|
|
return fragment;
|
|
}
|
|
|
|
_createItemsFragment(
|
|
items: Item[],
|
|
fragment: DocumentFragment = document.createDocumentFragment(),
|
|
): DocumentFragment {
|
|
// Create fragment to add elements to
|
|
const { shouldSortItems, sorter, removeItemButton } = this.config;
|
|
|
|
// If sorting is enabled, filter items
|
|
if (shouldSortItems && !this._isSelectOneElement) {
|
|
items.sort(sorter);
|
|
}
|
|
|
|
if (this._isTextElement) {
|
|
// Update the value of the hidden input
|
|
this.passedElement.value = items
|
|
.map(({ value }) => value)
|
|
.join(this.config.delimiter);
|
|
} else {
|
|
// Update the options of the hidden input
|
|
(this.passedElement as WrappedSelect).options = items;
|
|
}
|
|
|
|
const addItemToFragment = (item: Item): void => {
|
|
// Create new list element
|
|
const listItem = this._getTemplate('item', item, removeItemButton);
|
|
// Append it to list
|
|
fragment.appendChild(listItem);
|
|
};
|
|
|
|
// Add each list item to list
|
|
items.forEach(addItemToFragment);
|
|
|
|
return fragment;
|
|
}
|
|
|
|
_triggerChange(value): void {
|
|
if (value === undefined || value === null) {
|
|
return;
|
|
}
|
|
|
|
this.passedElement.triggerEvent(EVENTS.change, {
|
|
value,
|
|
});
|
|
}
|
|
|
|
_selectPlaceholderChoice(placeholderChoice: Choice): void {
|
|
this._addItem({
|
|
value: placeholderChoice.value,
|
|
label: placeholderChoice.label,
|
|
choiceId: placeholderChoice.id,
|
|
groupId: placeholderChoice.groupId,
|
|
placeholder: placeholderChoice.placeholder,
|
|
});
|
|
|
|
this._triggerChange(placeholderChoice.value);
|
|
}
|
|
|
|
_handleButtonAction(activeItems?: Item[], element?: HTMLElement): void {
|
|
if (
|
|
!activeItems ||
|
|
!element ||
|
|
!this.config.removeItems ||
|
|
!this.config.removeItemButton
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const itemId =
|
|
element.parentNode && (element.parentNode as HTMLElement).dataset.id;
|
|
const itemToRemove =
|
|
itemId && activeItems.find(item => item.id === parseInt(itemId, 10));
|
|
|
|
if (!itemToRemove) {
|
|
return;
|
|
}
|
|
|
|
// Remove item associated with button
|
|
this._removeItem(itemToRemove);
|
|
this._triggerChange(itemToRemove.value);
|
|
|
|
if (this._isSelectOneElement && this._store.placeholderChoice) {
|
|
this._selectPlaceholderChoice(this._store.placeholderChoice);
|
|
}
|
|
}
|
|
|
|
_handleItemAction(
|
|
activeItems?: Item[],
|
|
element?: HTMLElement,
|
|
hasShiftKey = false,
|
|
): void {
|
|
if (
|
|
!activeItems ||
|
|
!element ||
|
|
!this.config.removeItems ||
|
|
this._isSelectOneElement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const passedId = element.dataset.id;
|
|
|
|
// We only want to select one item with a click
|
|
// so we deselect any items that aren't the target
|
|
// unless shift is being pressed
|
|
activeItems.forEach(item => {
|
|
if (item.id === parseInt(`${passedId}`, 10) && !item.highlighted) {
|
|
this.highlightItem(item);
|
|
} else if (!hasShiftKey && item.highlighted) {
|
|
this.unhighlightItem(item);
|
|
}
|
|
});
|
|
|
|
// Focus input as without focus, a user cannot do anything with a
|
|
// highlighted item
|
|
this.input.focus();
|
|
}
|
|
|
|
_handleChoiceAction(activeItems?: Item[], element?: HTMLElement): void {
|
|
if (!activeItems || !element) {
|
|
return;
|
|
}
|
|
|
|
// If we are clicking on an option
|
|
const { id } = element.dataset;
|
|
const choice = id && this._store.getChoiceById(id);
|
|
if (!choice) {
|
|
return;
|
|
}
|
|
|
|
const passedKeyCode =
|
|
activeItems[0] && activeItems[0].keyCode
|
|
? activeItems[0].keyCode
|
|
: undefined;
|
|
const hasActiveDropdown = this.dropdown.isActive;
|
|
|
|
// Update choice keyCode
|
|
choice.keyCode = passedKeyCode;
|
|
|
|
this.passedElement.triggerEvent(EVENTS.choice, {
|
|
choice,
|
|
});
|
|
|
|
if (!choice.selected && !choice.disabled) {
|
|
const canAddItem = this._canAddItem(activeItems, choice.value);
|
|
|
|
if (canAddItem.response) {
|
|
this._addItem({
|
|
value: choice.value,
|
|
label: choice.label,
|
|
choiceId: choice.id,
|
|
groupId: choice.groupId,
|
|
customProperties: choice.customProperties,
|
|
placeholder: choice.placeholder,
|
|
keyCode: choice.keyCode,
|
|
});
|
|
|
|
this._triggerChange(choice.value);
|
|
}
|
|
}
|
|
|
|
this.clearInput();
|
|
|
|
// We want to close the dropdown if we are dealing with a single select box
|
|
if (hasActiveDropdown && this._isSelectOneElement) {
|
|
this.hideDropdown(true);
|
|
this.containerOuter.focus();
|
|
}
|
|
}
|
|
|
|
_handleBackspace(activeItems?: Item[]): void {
|
|
if (!this.config.removeItems || !activeItems) {
|
|
return;
|
|
}
|
|
|
|
const lastItem = activeItems[activeItems.length - 1];
|
|
const hasHighlightedItems = activeItems.some(item => item.highlighted);
|
|
|
|
// If editing the last item is allowed and there are not other selected items,
|
|
// we can edit the item value. Otherwise if we can remove items, remove all selected items
|
|
if (this.config.editItems && !hasHighlightedItems && lastItem) {
|
|
this.input.value = lastItem.value;
|
|
this.input.setWidth();
|
|
this._removeItem(lastItem);
|
|
this._triggerChange(lastItem.value);
|
|
} else {
|
|
if (!hasHighlightedItems) {
|
|
// Highlight last item if none already highlighted
|
|
this.highlightItem(lastItem, false);
|
|
}
|
|
this.removeHighlightedItems(true);
|
|
}
|
|
}
|
|
|
|
_startLoading(): void {
|
|
this._store.dispatch(setIsLoading(true));
|
|
}
|
|
|
|
_stopLoading(): void {
|
|
this._store.dispatch(setIsLoading(false));
|
|
}
|
|
|
|
_handleLoadingState(setLoading = true): void {
|
|
let placeholderItem = this.itemList.getChild(
|
|
`.${this.config.classNames.placeholder}`,
|
|
);
|
|
|
|
if (setLoading) {
|
|
this.disable();
|
|
this.containerOuter.addLoadingState();
|
|
|
|
if (this._isSelectOneElement) {
|
|
if (!placeholderItem) {
|
|
placeholderItem = this._getTemplate(
|
|
'placeholder',
|
|
this.config.loadingText,
|
|
);
|
|
|
|
if (placeholderItem) {
|
|
this.itemList.append(placeholderItem);
|
|
}
|
|
} else {
|
|
placeholderItem.innerHTML = this.config.loadingText;
|
|
}
|
|
} else {
|
|
this.input.placeholder = this.config.loadingText;
|
|
}
|
|
} else {
|
|
this.enable();
|
|
this.containerOuter.removeLoadingState();
|
|
|
|
if (this._isSelectOneElement) {
|
|
if (placeholderItem) {
|
|
placeholderItem.innerHTML = this._placeholderValue || '';
|
|
}
|
|
} else {
|
|
this.input.placeholder = this._placeholderValue || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleSearch(value: string): void {
|
|
if (!value || !this.input.isFocussed) {
|
|
return;
|
|
}
|
|
|
|
const { choices } = this._store;
|
|
const { searchFloor, searchChoices } = this.config;
|
|
const hasUnactiveChoices = choices.some(option => !option.active);
|
|
|
|
// Check that we have a value to search and the input was an alphanumeric character
|
|
if (value && value.length >= searchFloor) {
|
|
const resultCount = searchChoices ? this._searchChoices(value) : 0;
|
|
// Trigger search event
|
|
this.passedElement.triggerEvent(EVENTS.search, {
|
|
value,
|
|
resultCount,
|
|
});
|
|
} else if (hasUnactiveChoices) {
|
|
// Otherwise reset choices to active
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
}
|
|
}
|
|
|
|
_canAddItem(activeItems: Item[], value: string): Notice {
|
|
let canAddItem = true;
|
|
let notice =
|
|
typeof this.config.addItemText === 'function'
|
|
? this.config.addItemText(value)
|
|
: this.config.addItemText;
|
|
|
|
if (!this._isSelectOneElement) {
|
|
const isDuplicateValue = existsInArray(activeItems, value);
|
|
|
|
if (
|
|
this.config.maxItemCount > 0 &&
|
|
this.config.maxItemCount <= activeItems.length
|
|
) {
|
|
// If there is a max entry limit and we have reached that limit
|
|
// don't update
|
|
canAddItem = false;
|
|
notice =
|
|
typeof this.config.maxItemText === 'function'
|
|
? this.config.maxItemText(this.config.maxItemCount)
|
|
: this.config.maxItemText;
|
|
}
|
|
|
|
if (
|
|
!this.config.duplicateItemsAllowed &&
|
|
isDuplicateValue &&
|
|
canAddItem
|
|
) {
|
|
canAddItem = false;
|
|
notice =
|
|
typeof this.config.uniqueItemText === 'function'
|
|
? this.config.uniqueItemText(value)
|
|
: this.config.uniqueItemText;
|
|
}
|
|
|
|
if (
|
|
this._isTextElement &&
|
|
this.config.addItems &&
|
|
canAddItem &&
|
|
typeof this.config.addItemFilter === 'function' &&
|
|
!this.config.addItemFilter(value)
|
|
) {
|
|
canAddItem = false;
|
|
notice =
|
|
typeof this.config.customAddItemText === 'function'
|
|
? this.config.customAddItemText(value)
|
|
: this.config.customAddItemText;
|
|
}
|
|
}
|
|
|
|
return {
|
|
response: canAddItem,
|
|
notice,
|
|
};
|
|
}
|
|
|
|
_searchChoices(value: string): number {
|
|
const newValue = typeof value === 'string' ? value.trim() : value;
|
|
const currentValue =
|
|
typeof this._currentValue === 'string'
|
|
? this._currentValue.trim()
|
|
: this._currentValue;
|
|
|
|
if (newValue.length < 1 && newValue === `${currentValue} `) {
|
|
return 0;
|
|
}
|
|
|
|
// If new value matches the desired length and is not the same as the current value with a space
|
|
const haystack = this._store.searchableChoices;
|
|
const needle = newValue;
|
|
const keys = [...this.config.searchFields];
|
|
const options = Object.assign(this.config.fuseOptions, {
|
|
keys,
|
|
includeMatches: true,
|
|
});
|
|
const fuse = new Fuse(haystack, options);
|
|
const results: Result<Choice>[] = fuse.search(needle) as any[]; // see https://github.com/krisk/Fuse/issues/303
|
|
|
|
this._currentValue = newValue;
|
|
this._highlightPosition = 0;
|
|
this._isSearching = true;
|
|
this._store.dispatch(filterChoices(results));
|
|
|
|
return results.length;
|
|
}
|
|
|
|
_addEventListeners(): void {
|
|
const { documentElement } = document;
|
|
|
|
// capture events - can cancel event processing or propagation
|
|
documentElement.addEventListener('touchend', this._onTouchEnd, true);
|
|
this.containerOuter.element.addEventListener(
|
|
'keydown',
|
|
this._onKeyDown,
|
|
true,
|
|
);
|
|
this.containerOuter.element.addEventListener(
|
|
'mousedown',
|
|
this._onMouseDown,
|
|
true,
|
|
);
|
|
|
|
// passive events - doesn't call `preventDefault` or `stopPropagation`
|
|
documentElement.addEventListener('click', this._onClick, { passive: true });
|
|
documentElement.addEventListener('touchmove', this._onTouchMove, {
|
|
passive: true,
|
|
});
|
|
this.dropdown.element.addEventListener('mouseover', this._onMouseOver, {
|
|
passive: true,
|
|
});
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.containerOuter.element.addEventListener('focus', this._onFocus, {
|
|
passive: true,
|
|
});
|
|
this.containerOuter.element.addEventListener('blur', this._onBlur, {
|
|
passive: true,
|
|
});
|
|
}
|
|
|
|
this.input.element.addEventListener('keyup', this._onKeyUp, {
|
|
passive: true,
|
|
});
|
|
|
|
this.input.element.addEventListener('focus', this._onFocus, {
|
|
passive: true,
|
|
});
|
|
this.input.element.addEventListener('blur', this._onBlur, {
|
|
passive: true,
|
|
});
|
|
|
|
if (this.input.element.form) {
|
|
this.input.element.form.addEventListener('reset', this._onFormReset, {
|
|
passive: true,
|
|
});
|
|
}
|
|
|
|
this.input.addEventListeners();
|
|
}
|
|
|
|
_removeEventListeners(): void {
|
|
const { documentElement } = document;
|
|
|
|
documentElement.removeEventListener('touchend', this._onTouchEnd, true);
|
|
this.containerOuter.element.removeEventListener(
|
|
'keydown',
|
|
this._onKeyDown,
|
|
true,
|
|
);
|
|
this.containerOuter.element.removeEventListener(
|
|
'mousedown',
|
|
this._onMouseDown,
|
|
true,
|
|
);
|
|
|
|
documentElement.removeEventListener('click', this._onClick);
|
|
documentElement.removeEventListener('touchmove', this._onTouchMove);
|
|
this.dropdown.element.removeEventListener('mouseover', this._onMouseOver);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.containerOuter.element.removeEventListener('focus', this._onFocus);
|
|
this.containerOuter.element.removeEventListener('blur', this._onBlur);
|
|
}
|
|
|
|
this.input.element.removeEventListener('keyup', this._onKeyUp);
|
|
this.input.element.removeEventListener('focus', this._onFocus);
|
|
this.input.element.removeEventListener('blur', this._onBlur);
|
|
|
|
if (this.input.element.form) {
|
|
this.input.element.form.removeEventListener('reset', this._onFormReset);
|
|
}
|
|
|
|
this.input.removeEventListeners();
|
|
}
|
|
|
|
_onKeyDown(event: KeyboardEvent): void {
|
|
const { keyCode } = event;
|
|
const { activeItems } = this._store;
|
|
const hasFocusedInput = this.input.isFocussed;
|
|
const hasActiveDropdown = this.dropdown.isActive;
|
|
const hasItems = this.itemList.hasChildren();
|
|
const keyString = String.fromCharCode(keyCode);
|
|
const wasAlphaNumericChar = /[a-zA-Z0-9-_ ]/.test(keyString);
|
|
|
|
const {
|
|
BACK_KEY,
|
|
DELETE_KEY,
|
|
ENTER_KEY,
|
|
A_KEY,
|
|
ESC_KEY,
|
|
UP_KEY,
|
|
DOWN_KEY,
|
|
PAGE_UP_KEY,
|
|
PAGE_DOWN_KEY,
|
|
} = KEY_CODES;
|
|
|
|
if (!this._isTextElement && !hasActiveDropdown && wasAlphaNumericChar) {
|
|
this.showDropdown();
|
|
|
|
if (!this.input.isFocussed) {
|
|
/*
|
|
We update the input value with the pressed key as
|
|
the input was not focussed at the time of key press
|
|
therefore does not have the value of the key.
|
|
*/
|
|
this.input.value += keyString.toLowerCase();
|
|
}
|
|
}
|
|
|
|
switch (keyCode) {
|
|
case A_KEY:
|
|
return this._onSelectKey(event, hasItems);
|
|
case ENTER_KEY:
|
|
return this._onEnterKey(event, activeItems, hasActiveDropdown);
|
|
case ESC_KEY:
|
|
return this._onEscapeKey(hasActiveDropdown);
|
|
case UP_KEY:
|
|
case PAGE_UP_KEY:
|
|
case DOWN_KEY:
|
|
case PAGE_DOWN_KEY:
|
|
return this._onDirectionKey(event, hasActiveDropdown);
|
|
case DELETE_KEY:
|
|
case BACK_KEY:
|
|
return this._onDeleteKey(event, activeItems, hasFocusedInput);
|
|
default:
|
|
}
|
|
}
|
|
|
|
_onKeyUp({
|
|
target,
|
|
keyCode,
|
|
}: Pick<KeyboardEvent, 'target' | 'keyCode'>): void {
|
|
const { value } = this.input;
|
|
const { activeItems } = this._store;
|
|
const canAddItem = this._canAddItem(activeItems, value);
|
|
const { BACK_KEY: backKey, DELETE_KEY: deleteKey } = KEY_CODES;
|
|
|
|
// We are typing into a text input and have a value, we want to show a dropdown
|
|
// notice. Otherwise hide the dropdown
|
|
if (this._isTextElement) {
|
|
const canShowDropdownNotice = canAddItem.notice && value;
|
|
|
|
if (canShowDropdownNotice) {
|
|
const dropdownItem = this._getTemplate('notice', canAddItem.notice);
|
|
this.dropdown.element.innerHTML = dropdownItem.outerHTML;
|
|
this.showDropdown(true);
|
|
} else {
|
|
this.hideDropdown(true);
|
|
}
|
|
} else {
|
|
const wasRemovalKeyCode = keyCode === backKey || keyCode === deleteKey;
|
|
const userHasRemovedValue =
|
|
wasRemovalKeyCode && target && !(target as HTMLSelectElement).value;
|
|
const canReactivateChoices = !this._isTextElement && this._isSearching;
|
|
const canSearch = this._canSearch && canAddItem.response;
|
|
|
|
if (userHasRemovedValue && canReactivateChoices) {
|
|
this._isSearching = false;
|
|
this._store.dispatch(activateChoices(true));
|
|
} else if (canSearch) {
|
|
this._handleSearch(this.input.value);
|
|
}
|
|
}
|
|
|
|
this._canSearch = this.config.searchEnabled;
|
|
}
|
|
|
|
_onSelectKey(event: KeyboardEvent, hasItems: boolean): void {
|
|
const { ctrlKey, metaKey } = event;
|
|
const hasCtrlDownKeyPressed = ctrlKey || metaKey;
|
|
|
|
// If CTRL + A or CMD + A have been pressed and there are items to select
|
|
if (hasCtrlDownKeyPressed && hasItems) {
|
|
this._canSearch = false;
|
|
|
|
const shouldHightlightAll =
|
|
this.config.removeItems &&
|
|
!this.input.value &&
|
|
this.input.element === document.activeElement;
|
|
|
|
if (shouldHightlightAll) {
|
|
this.highlightAll();
|
|
}
|
|
}
|
|
}
|
|
|
|
_onEnterKey(
|
|
event: KeyboardEvent,
|
|
activeItems: Item[],
|
|
hasActiveDropdown: boolean,
|
|
): void {
|
|
const { target } = event;
|
|
const { ENTER_KEY: enterKey } = KEY_CODES;
|
|
const targetWasButton =
|
|
target && (target as HTMLElement).hasAttribute('data-button');
|
|
|
|
if (this._isTextElement && target && (target as HTMLInputElement).value) {
|
|
const { value } = this.input;
|
|
const canAddItem = this._canAddItem(activeItems, value);
|
|
|
|
if (canAddItem.response) {
|
|
this.hideDropdown(true);
|
|
this._addItem({ value });
|
|
this._triggerChange(value);
|
|
this.clearInput();
|
|
}
|
|
}
|
|
|
|
if (targetWasButton) {
|
|
this._handleButtonAction(activeItems, target as HTMLElement);
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (hasActiveDropdown) {
|
|
const highlightedChoice = this.dropdown.getChild(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
);
|
|
|
|
if (highlightedChoice) {
|
|
// add enter keyCode value
|
|
if (activeItems[0]) {
|
|
activeItems[0].keyCode = enterKey; // eslint-disable-line no-param-reassign
|
|
}
|
|
this._handleChoiceAction(activeItems, highlightedChoice);
|
|
}
|
|
|
|
event.preventDefault();
|
|
} else if (this._isSelectOneElement) {
|
|
this.showDropdown();
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onEscapeKey(hasActiveDropdown: boolean): void {
|
|
if (hasActiveDropdown) {
|
|
this.hideDropdown(true);
|
|
this.containerOuter.focus();
|
|
}
|
|
}
|
|
|
|
_onDirectionKey(event: KeyboardEvent, hasActiveDropdown: boolean): void {
|
|
const { keyCode, metaKey } = event;
|
|
const {
|
|
DOWN_KEY: downKey,
|
|
PAGE_UP_KEY: pageUpKey,
|
|
PAGE_DOWN_KEY: pageDownKey,
|
|
} = KEY_CODES;
|
|
|
|
// If up or down key is pressed, traverse through options
|
|
if (hasActiveDropdown || this._isSelectOneElement) {
|
|
this.showDropdown();
|
|
this._canSearch = false;
|
|
|
|
const directionInt =
|
|
keyCode === downKey || keyCode === pageDownKey ? 1 : -1;
|
|
const skipKey =
|
|
metaKey || keyCode === pageDownKey || keyCode === pageUpKey;
|
|
const selectableChoiceIdentifier = '[data-choice-selectable]';
|
|
|
|
let nextEl;
|
|
if (skipKey) {
|
|
if (directionInt > 0) {
|
|
nextEl = this.dropdown.element.querySelector(
|
|
`${selectableChoiceIdentifier}:last-of-type`,
|
|
);
|
|
} else {
|
|
nextEl = this.dropdown.element.querySelector(
|
|
selectableChoiceIdentifier,
|
|
);
|
|
}
|
|
} else {
|
|
const currentEl = this.dropdown.element.querySelector(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
);
|
|
if (currentEl) {
|
|
nextEl = getAdjacentEl(
|
|
currentEl,
|
|
selectableChoiceIdentifier,
|
|
directionInt,
|
|
);
|
|
} else {
|
|
nextEl = this.dropdown.element.querySelector(
|
|
selectableChoiceIdentifier,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (nextEl) {
|
|
// We prevent default to stop the cursor moving
|
|
// when pressing the arrow
|
|
if (
|
|
!isScrolledIntoView(nextEl, this.choiceList.element, directionInt)
|
|
) {
|
|
this.choiceList.scrollToChildElement(nextEl, directionInt);
|
|
}
|
|
this._highlightChoice(nextEl);
|
|
}
|
|
|
|
// Prevent default to maintain cursor position whilst
|
|
// traversing dropdown options
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onDeleteKey(
|
|
event: KeyboardEvent,
|
|
activeItems: Item[],
|
|
hasFocusedInput: boolean,
|
|
): void {
|
|
const { target } = event;
|
|
// If backspace or delete key is pressed and the input has no value
|
|
if (
|
|
!this._isSelectOneElement &&
|
|
!(target as HTMLInputElement).value &&
|
|
hasFocusedInput
|
|
) {
|
|
this._handleBackspace(activeItems);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onTouchMove(): void {
|
|
if (this._wasTap) {
|
|
this._wasTap = false;
|
|
}
|
|
}
|
|
|
|
_onTouchEnd(event: TouchEvent): void {
|
|
const { target } = event || (event as TouchEvent).touches[0];
|
|
const touchWasWithinContainer =
|
|
this._wasTap && this.containerOuter.element.contains(target as Node);
|
|
|
|
if (touchWasWithinContainer) {
|
|
const containerWasExactTarget =
|
|
target === this.containerOuter.element ||
|
|
target === this.containerInner.element;
|
|
|
|
if (containerWasExactTarget) {
|
|
if (this._isTextElement) {
|
|
this.input.focus();
|
|
} else if (this._isSelectMultipleElement) {
|
|
this.showDropdown();
|
|
}
|
|
}
|
|
|
|
// Prevents focus event firing
|
|
event.stopPropagation();
|
|
}
|
|
|
|
this._wasTap = true;
|
|
}
|
|
|
|
/**
|
|
* Handles mousedown event in capture mode for containetOuter.element
|
|
*/
|
|
_onMouseDown(event: MouseEvent): void {
|
|
const { target } = event;
|
|
if (!(target instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
// If we have our mouse down on the scrollbar and are on IE11...
|
|
if (IS_IE11 && this.choiceList.element.contains(target)) {
|
|
// check if click was on a scrollbar area
|
|
const firstChoice = this.choiceList.element
|
|
.firstElementChild as HTMLElement;
|
|
|
|
const isOnScrollbar =
|
|
this._direction === 'ltr'
|
|
? event.offsetX >= firstChoice.offsetWidth
|
|
: event.offsetX < firstChoice.offsetLeft;
|
|
this._isScrollingOnIe = isOnScrollbar;
|
|
}
|
|
|
|
if (target === this.input.element) {
|
|
return;
|
|
}
|
|
|
|
const item = target.closest('[data-button],[data-item],[data-choice]');
|
|
if (item instanceof HTMLElement) {
|
|
const hasShiftKey = event.shiftKey;
|
|
const { activeItems } = this._store;
|
|
const { dataset } = item;
|
|
|
|
if ('button' in dataset) {
|
|
this._handleButtonAction(activeItems, item);
|
|
} else if ('item' in dataset) {
|
|
this._handleItemAction(activeItems, item, hasShiftKey);
|
|
} else if ('choice' in dataset) {
|
|
this._handleChoiceAction(activeItems, item);
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Handles mouseover event over this.dropdown
|
|
* @param {MouseEvent} event
|
|
*/
|
|
_onMouseOver({ target }: Pick<MouseEvent, 'target'>): void {
|
|
if (target instanceof HTMLElement && 'choice' in target.dataset) {
|
|
this._highlightChoice(target);
|
|
}
|
|
}
|
|
|
|
_onClick({ target }: Pick<MouseEvent, 'target'>): void {
|
|
const clickWasWithinContainer = this.containerOuter.element.contains(
|
|
target as Node,
|
|
);
|
|
|
|
if (clickWasWithinContainer) {
|
|
if (!this.dropdown.isActive && !this.containerOuter.isDisabled) {
|
|
if (this._isTextElement) {
|
|
if (document.activeElement !== this.input.element) {
|
|
this.input.focus();
|
|
}
|
|
} else {
|
|
this.showDropdown();
|
|
this.containerOuter.focus();
|
|
}
|
|
} else if (
|
|
this._isSelectOneElement &&
|
|
target !== this.input.element &&
|
|
!this.dropdown.element.contains(target as Node)
|
|
) {
|
|
this.hideDropdown();
|
|
}
|
|
} else {
|
|
const hasHighlightedItems = this._store.highlightedActiveItems.length > 0;
|
|
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
|
|
this.containerOuter.removeFocusState();
|
|
this.hideDropdown(true);
|
|
}
|
|
}
|
|
|
|
_onFocus({ target }: Pick<FocusEvent, 'target'>): void {
|
|
const focusWasWithinContainer =
|
|
target && this.containerOuter.element.contains(target as Node);
|
|
|
|
if (!focusWasWithinContainer) {
|
|
return;
|
|
}
|
|
|
|
const focusActions = {
|
|
[TEXT_TYPE]: (): void => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.addFocusState();
|
|
}
|
|
},
|
|
[SELECT_ONE_TYPE]: (): void => {
|
|
this.containerOuter.addFocusState();
|
|
if (target === this.input.element) {
|
|
this.showDropdown(true);
|
|
}
|
|
},
|
|
[SELECT_MULTIPLE_TYPE]: (): void => {
|
|
if (target === this.input.element) {
|
|
this.showDropdown(true);
|
|
// If element is a select box, the focused element is the container and the dropdown
|
|
// isn't already open, focus and show dropdown
|
|
this.containerOuter.addFocusState();
|
|
}
|
|
},
|
|
};
|
|
|
|
focusActions[this.passedElement.element.type]();
|
|
}
|
|
|
|
_onBlur({ target }: Pick<FocusEvent, 'target'>): void {
|
|
const blurWasWithinContainer =
|
|
target && this.containerOuter.element.contains(target as Node);
|
|
|
|
if (blurWasWithinContainer && !this._isScrollingOnIe) {
|
|
const { activeItems } = this._store;
|
|
const hasHighlightedItems = activeItems.some(item => item.highlighted);
|
|
const blurActions = {
|
|
[TEXT_TYPE]: (): void => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.removeFocusState();
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
this.hideDropdown(true);
|
|
}
|
|
},
|
|
[SELECT_ONE_TYPE]: (): void => {
|
|
this.containerOuter.removeFocusState();
|
|
if (
|
|
target === this.input.element ||
|
|
(target === this.containerOuter.element && !this._canSearch)
|
|
) {
|
|
this.hideDropdown(true);
|
|
}
|
|
},
|
|
[SELECT_MULTIPLE_TYPE]: (): void => {
|
|
if (target === this.input.element) {
|
|
this.containerOuter.removeFocusState();
|
|
this.hideDropdown(true);
|
|
if (hasHighlightedItems) {
|
|
this.unhighlightAll();
|
|
}
|
|
}
|
|
},
|
|
};
|
|
|
|
blurActions[this.passedElement.element.type]();
|
|
} else {
|
|
// On IE11, clicking the scollbar blurs our input and thus
|
|
// closes the dropdown. To stop this, we refocus our input
|
|
// if we know we are on IE *and* are scrolling.
|
|
this._isScrollingOnIe = false;
|
|
this.input.element.focus();
|
|
}
|
|
}
|
|
|
|
_onFormReset(): void {
|
|
this._store.dispatch(resetTo(this._initialState));
|
|
}
|
|
|
|
_highlightChoice(el: HTMLElement | null = null): void {
|
|
const choices: HTMLElement[] = Array.from(
|
|
this.dropdown.element.querySelectorAll('[data-choice-selectable]'),
|
|
);
|
|
|
|
if (!choices.length) {
|
|
return;
|
|
}
|
|
|
|
let passedEl = el;
|
|
const highlightedChoices = Array.from(
|
|
this.dropdown.element.querySelectorAll(
|
|
`.${this.config.classNames.highlightedState}`,
|
|
),
|
|
);
|
|
|
|
// Remove any highlighted choices
|
|
highlightedChoices.forEach(choice => {
|
|
choice.classList.remove(...this.config.classNames.highlightedState.split(" "));
|
|
choice.setAttribute('aria-selected', 'false');
|
|
});
|
|
|
|
if (passedEl) {
|
|
this._highlightPosition = choices.indexOf(passedEl);
|
|
} else {
|
|
// Highlight choice based on last known highlight location
|
|
if (choices.length > this._highlightPosition) {
|
|
// If we have an option to highlight
|
|
passedEl = choices[this._highlightPosition];
|
|
} else {
|
|
// Otherwise highlight the option before
|
|
passedEl = choices[choices.length - 1];
|
|
}
|
|
|
|
if (!passedEl) {
|
|
passedEl = choices[0];
|
|
}
|
|
}
|
|
|
|
passedEl.classList.add(...this.config.classNames.highlightedState.split(" "));
|
|
passedEl.setAttribute('aria-selected', 'true');
|
|
this.passedElement.triggerEvent(EVENTS.highlightChoice, { el: passedEl });
|
|
|
|
if (this.dropdown.isActive) {
|
|
// IE11 ignores aria-label and blocks virtual keyboard
|
|
// if aria-activedescendant is set without a dropdown
|
|
this.input.setActiveDescendant(passedEl.id);
|
|
this.containerOuter.setActiveDescendant(passedEl.id);
|
|
}
|
|
}
|
|
|
|
_addItem({
|
|
value,
|
|
label = null,
|
|
choiceId = -1,
|
|
groupId = -1,
|
|
customProperties = {},
|
|
placeholder = false,
|
|
keyCode = -1,
|
|
}: {
|
|
value: string;
|
|
label?: string | null;
|
|
choiceId?: number;
|
|
groupId?: number;
|
|
customProperties?: object;
|
|
placeholder?: boolean;
|
|
keyCode?: number;
|
|
}): void {
|
|
let passedValue = typeof value === 'string' ? value.trim() : value;
|
|
|
|
const { items } = this._store;
|
|
const passedLabel = label || passedValue;
|
|
const passedOptionId = choiceId || -1;
|
|
const group = groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
const id = items ? items.length + 1 : 1;
|
|
|
|
// If a prepended value has been passed, prepend it
|
|
if (this.config.prependValue) {
|
|
passedValue = this.config.prependValue + passedValue.toString();
|
|
}
|
|
|
|
// If an appended value has been passed, append it
|
|
if (this.config.appendValue) {
|
|
passedValue += this.config.appendValue.toString();
|
|
}
|
|
|
|
this._store.dispatch(
|
|
addItem({
|
|
value: passedValue,
|
|
label: passedLabel,
|
|
id,
|
|
choiceId: passedOptionId,
|
|
groupId,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode,
|
|
}),
|
|
);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.removeActiveItems(id);
|
|
}
|
|
|
|
// Trigger change event
|
|
this.passedElement.triggerEvent(EVENTS.addItem, {
|
|
id,
|
|
value: passedValue,
|
|
label: passedLabel,
|
|
customProperties,
|
|
groupValue: group && group.value ? group.value : null,
|
|
keyCode,
|
|
});
|
|
}
|
|
|
|
_removeItem(item: Item): void {
|
|
const { id, value, label, customProperties, choiceId, groupId } = item;
|
|
const group =
|
|
groupId && groupId >= 0 ? this._store.getGroupById(groupId) : null;
|
|
|
|
if (!id || !choiceId) {
|
|
return;
|
|
}
|
|
|
|
this._store.dispatch(removeItem(id, choiceId));
|
|
this.passedElement.triggerEvent(EVENTS.removeItem, {
|
|
id,
|
|
value,
|
|
label,
|
|
customProperties,
|
|
groupValue: group && group.value ? group.value : null,
|
|
});
|
|
}
|
|
|
|
_addChoice({
|
|
value,
|
|
label = null,
|
|
isSelected = false,
|
|
isDisabled = false,
|
|
groupId = -1,
|
|
customProperties = {},
|
|
placeholder = false,
|
|
keyCode = -1,
|
|
}: {
|
|
value: string;
|
|
label?: string | null;
|
|
isSelected?: boolean;
|
|
isDisabled?: boolean;
|
|
groupId?: number;
|
|
customProperties?: Record<string, any>;
|
|
placeholder?: boolean;
|
|
keyCode?: number;
|
|
}): void {
|
|
if (typeof value === 'undefined' || value === null) {
|
|
return;
|
|
}
|
|
|
|
// Generate unique id
|
|
const { choices } = this._store;
|
|
const choiceLabel = label || value;
|
|
const choiceId = choices ? choices.length + 1 : 1;
|
|
const choiceElementId = `${this._baseId}-${this._idNames.itemChoice}-${choiceId}`;
|
|
|
|
this._store.dispatch(
|
|
addChoice({
|
|
id: choiceId,
|
|
groupId,
|
|
elementId: choiceElementId,
|
|
value,
|
|
label: choiceLabel,
|
|
disabled: isDisabled,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode,
|
|
}),
|
|
);
|
|
|
|
if (isSelected) {
|
|
this._addItem({
|
|
value,
|
|
label: choiceLabel,
|
|
choiceId,
|
|
customProperties,
|
|
placeholder,
|
|
keyCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
_addGroup({ group, id, valueKey = 'value', labelKey = 'label' }): void {
|
|
const groupChoices: Choice[] | HTMLOptionElement[] = isType('Object', group)
|
|
? group.choices
|
|
: Array.from(group.getElementsByTagName('OPTION'));
|
|
const groupId = id || Math.floor(new Date().valueOf() * Math.random());
|
|
const isDisabled = group.disabled ? group.disabled : false;
|
|
|
|
if (groupChoices) {
|
|
this._store.dispatch(
|
|
addGroup({
|
|
value: group.label,
|
|
id: groupId,
|
|
active: true,
|
|
disabled: isDisabled,
|
|
}),
|
|
);
|
|
|
|
const addGroupChoices = (choice: any): void => {
|
|
const isOptDisabled =
|
|
choice.disabled || (choice.parentNode && choice.parentNode.disabled);
|
|
|
|
this._addChoice({
|
|
value: choice[valueKey],
|
|
label: isType('Object', choice) ? choice[labelKey] : choice.innerHTML,
|
|
isSelected: choice.selected,
|
|
isDisabled: isOptDisabled,
|
|
groupId,
|
|
customProperties: choice.customProperties,
|
|
placeholder: choice.placeholder,
|
|
});
|
|
};
|
|
|
|
groupChoices.forEach(addGroupChoices);
|
|
} else {
|
|
this._store.dispatch(
|
|
addGroup({
|
|
value: group.label,
|
|
id: group.id,
|
|
active: false,
|
|
disabled: group.disabled,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
_getTemplate(template: string, ...args: any): any {
|
|
const { classNames } = this.config;
|
|
|
|
return this._templates[template].call(this, classNames, ...args);
|
|
}
|
|
|
|
_createTemplates(): void {
|
|
const { callbackOnCreateTemplates } = this.config;
|
|
let userTemplates = {};
|
|
|
|
if (
|
|
callbackOnCreateTemplates &&
|
|
typeof callbackOnCreateTemplates === 'function'
|
|
) {
|
|
userTemplates = callbackOnCreateTemplates.call(this, strToEl);
|
|
}
|
|
|
|
this._templates = merge(templates, userTemplates);
|
|
}
|
|
|
|
_createElements(): void {
|
|
this.containerOuter = new Container({
|
|
element: this._getTemplate(
|
|
'containerOuter',
|
|
this._direction,
|
|
this._isSelectElement,
|
|
this._isSelectOneElement,
|
|
this.config.searchEnabled,
|
|
this.passedElement.element.type,
|
|
),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type as PassedElement['type'],
|
|
position: this.config.position,
|
|
});
|
|
|
|
this.containerInner = new Container({
|
|
element: this._getTemplate('containerInner'),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type as PassedElement['type'],
|
|
position: this.config.position,
|
|
});
|
|
|
|
this.input = new Input({
|
|
element: this._getTemplate('input', this._placeholderValue),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type as PassedElement['type'],
|
|
preventPaste: !this.config.paste,
|
|
});
|
|
|
|
this.choiceList = new List({
|
|
element: this._getTemplate('choiceList', this._isSelectOneElement),
|
|
});
|
|
|
|
this.itemList = new List({
|
|
element: this._getTemplate('itemList', this._isSelectOneElement),
|
|
});
|
|
|
|
this.dropdown = new Dropdown({
|
|
element: this._getTemplate('dropdown'),
|
|
classNames: this.config.classNames,
|
|
type: this.passedElement.element.type as PassedElement['type'],
|
|
});
|
|
}
|
|
|
|
_createStructure(): void {
|
|
// Hide original element
|
|
this.passedElement.conceal();
|
|
// Wrap input in container preserving DOM ordering
|
|
this.containerInner.wrap(this.passedElement.element);
|
|
// Wrapper inner container with outer container
|
|
this.containerOuter.wrap(this.containerInner.element);
|
|
|
|
if (this._isSelectOneElement) {
|
|
this.input.placeholder = this.config.searchPlaceholderValue || '';
|
|
} else if (this._placeholderValue) {
|
|
this.input.placeholder = this._placeholderValue;
|
|
this.input.setWidth();
|
|
}
|
|
|
|
this.containerOuter.element.appendChild(this.containerInner.element);
|
|
this.containerOuter.element.appendChild(this.dropdown.element);
|
|
this.containerInner.element.appendChild(this.itemList.element);
|
|
|
|
if (!this._isTextElement) {
|
|
this.dropdown.element.appendChild(this.choiceList.element);
|
|
}
|
|
|
|
if (!this._isSelectOneElement) {
|
|
this.containerInner.element.appendChild(this.input.element);
|
|
} else if (this.config.searchEnabled) {
|
|
this.dropdown.element.insertBefore(
|
|
this.input.element,
|
|
this.dropdown.element.firstChild,
|
|
);
|
|
}
|
|
|
|
if (this._isSelectElement) {
|
|
this._highlightPosition = 0;
|
|
this._isSearching = false;
|
|
this._startLoading();
|
|
|
|
if (this._presetGroups.length) {
|
|
this._addPredefinedGroups(this._presetGroups);
|
|
} else {
|
|
this._addPredefinedChoices(this._presetChoices);
|
|
}
|
|
|
|
this._stopLoading();
|
|
}
|
|
|
|
if (this._isTextElement) {
|
|
this._addPredefinedItems(this._presetItems);
|
|
}
|
|
}
|
|
|
|
_addPredefinedGroups(
|
|
groups: Group[] | HTMLOptGroupElement[] | Element[],
|
|
): void {
|
|
// If we have a placeholder option
|
|
const placeholderChoice = (this.passedElement as WrappedSelect)
|
|
.placeholderOption;
|
|
|
|
if (
|
|
placeholderChoice &&
|
|
placeholderChoice.parentNode &&
|
|
(placeholderChoice.parentNode as HTMLElement).tagName === 'SELECT'
|
|
) {
|
|
this._addChoice({
|
|
value: placeholderChoice.value,
|
|
label: placeholderChoice.innerHTML,
|
|
isSelected: placeholderChoice.selected,
|
|
isDisabled: placeholderChoice.disabled,
|
|
placeholder: true,
|
|
});
|
|
}
|
|
|
|
groups.forEach(group =>
|
|
this._addGroup({
|
|
group,
|
|
id: group.id || null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
_addPredefinedChoices(choices: Partial<Choice>[]): void {
|
|
// If sorting is enabled or the user is searching, filter choices
|
|
if (this.config.shouldSort) {
|
|
choices.sort(this.config.sorter);
|
|
}
|
|
|
|
const hasSelectedChoice = choices.some(choice => choice.selected);
|
|
const firstEnabledChoiceIndex = choices.findIndex(
|
|
choice => choice.disabled === undefined || !choice.disabled,
|
|
);
|
|
|
|
choices.forEach((choice, index) => {
|
|
const { value = '', label, customProperties, placeholder } = choice;
|
|
|
|
if (this._isSelectElement) {
|
|
// If the choice is actually a group
|
|
if (choice.choices) {
|
|
this._addGroup({
|
|
group: choice,
|
|
id: choice.id || null,
|
|
});
|
|
} else {
|
|
/**
|
|
* If there is a selected choice already or the choice is not the first in
|
|
* the array, add each choice normally.
|
|
*
|
|
* Otherwise we pre-select the first enabled choice in the array ("select-one" only)
|
|
*/
|
|
const shouldPreselect =
|
|
this._isSelectOneElement &&
|
|
!hasSelectedChoice &&
|
|
index === firstEnabledChoiceIndex;
|
|
|
|
const isSelected = shouldPreselect ? true : choice.selected;
|
|
const isDisabled = choice.disabled;
|
|
|
|
console.log(isDisabled, choice);
|
|
|
|
this._addChoice({
|
|
value,
|
|
label,
|
|
isSelected: !!isSelected,
|
|
isDisabled: !!isDisabled,
|
|
placeholder: !!placeholder,
|
|
customProperties,
|
|
});
|
|
}
|
|
} else {
|
|
this._addChoice({
|
|
value,
|
|
label,
|
|
isSelected: !!choice.selected,
|
|
isDisabled: !!choice.disabled,
|
|
placeholder: !!choice.placeholder,
|
|
customProperties,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
_addPredefinedItems(items: Item[] | string[]): void {
|
|
items.forEach(item => {
|
|
if (typeof item === 'object' && item.value) {
|
|
this._addItem({
|
|
value: item.value,
|
|
label: item.label,
|
|
choiceId: item.id,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
}
|
|
|
|
if (typeof item === 'string') {
|
|
this._addItem({
|
|
value: item,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
_setChoiceOrItem(item: any): void {
|
|
const itemType = getType(item).toLowerCase();
|
|
const handleType = {
|
|
object: (): void => {
|
|
if (!item.value) {
|
|
return;
|
|
}
|
|
|
|
// If we are dealing with a select input, we need to create an option first
|
|
// that is then selected. For text inputs we can just add items normally.
|
|
if (!this._isTextElement) {
|
|
this._addChoice({
|
|
value: item.value,
|
|
label: item.label,
|
|
isSelected: true,
|
|
isDisabled: false,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
} else {
|
|
this._addItem({
|
|
value: item.value,
|
|
label: item.label,
|
|
choiceId: item.id,
|
|
customProperties: item.customProperties,
|
|
placeholder: item.placeholder,
|
|
});
|
|
}
|
|
},
|
|
string: (): void => {
|
|
if (!this._isTextElement) {
|
|
this._addChoice({
|
|
value: item,
|
|
label: item,
|
|
isSelected: true,
|
|
isDisabled: false,
|
|
});
|
|
} else {
|
|
this._addItem({
|
|
value: item,
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
handleType[itemType]();
|
|
}
|
|
|
|
_findAndSelectChoiceByValue(value: string): void {
|
|
const { choices } = this._store;
|
|
// Check 'value' property exists and the choice isn't already selected
|
|
const foundChoice = choices.find(choice =>
|
|
this.config.valueComparer(choice.value, value),
|
|
);
|
|
|
|
if (foundChoice && !foundChoice.selected) {
|
|
this._addItem({
|
|
value: foundChoice.value,
|
|
label: foundChoice.label,
|
|
choiceId: foundChoice.id,
|
|
groupId: foundChoice.groupId,
|
|
customProperties: foundChoice.customProperties,
|
|
placeholder: foundChoice.placeholder,
|
|
keyCode: foundChoice.keyCode,
|
|
});
|
|
}
|
|
}
|
|
|
|
_generatePlaceholderValue(): string | null {
|
|
if (
|
|
this._isSelectElement &&
|
|
(this.passedElement as WrappedSelect).placeholderOption
|
|
) {
|
|
const { placeholderOption } = this.passedElement as WrappedSelect;
|
|
|
|
return placeholderOption ? placeholderOption.text : null;
|
|
}
|
|
|
|
const { placeholder, placeholderValue } = this.config;
|
|
const {
|
|
element: { dataset },
|
|
} = this.passedElement;
|
|
|
|
if (placeholder) {
|
|
if (placeholderValue) {
|
|
return placeholderValue;
|
|
}
|
|
|
|
if (dataset.placeholder) {
|
|
return dataset.placeholder;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default Choices;
|