choices.js
Version:
A vanilla JS customisable text input/select box plugin
1,645 lines (1,373 loc) • 71.3 kB
text/typescript
import { activateChoices, addChoice, removeChoice, filterChoices } from './actions/choices';
import { addGroup } from './actions/groups';
import { addItem, highlightItem, removeItem } from './actions/items';
import { Container, Dropdown, Input, List, WrappedInput, WrappedSelect } from './components';
import { DEFAULT_CONFIG } from './defaults';
import { InputChoice } from './interfaces/input-choice';
import { InputGroup } from './interfaces/input-group';
import { Options, ObjectsInConfig } from './interfaces/options';
import { StateChangeSet } from './interfaces/state';
import {
addClassesToElement,
diff,
escapeForTemplate,
generateId,
getAdjacentEl,
getClassNames,
getClassNamesSelector,
isScrolledIntoView,
removeClassesFromElement,
resolveNoticeFunction,
resolveStringFunction,
sortByRank,
strToEl,
unwrapStringForEscaped,
} from './lib/utils';
import Store from './store/store';
import { coerceBool, mapInputToChoice } from './lib/choice-input';
import { ChoiceFull } from './interfaces/choice-full';
import { GroupFull } from './interfaces/group-full';
import { EventChoiceValueType, EventType, KeyCodeMap, PassedElementType, PassedElementTypes } from './interfaces';
import { EventChoice } from './interfaces/event-choice';
import { NoticeType, NoticeTypes, Templates } from './interfaces/templates';
import { isHtmlInputElement, isHtmlSelectElement } from './lib/html-guard-statements';
import { Searcher } from './interfaces/search';
import { getSearcher } from './search';
// eslint-disable-next-line import/no-named-default
import { default as defaultTemplates } from './templates';
import { canUseDom } from './interfaces/build-flags';
/** @see {@link http://browserhacks.com/#hack-acea075d0ac6954f275a70023906050c} */
const IS_IE11 =
canUseDom &&
'-ms-scroll-limit' in document.documentElement.style &&
'-ms-ime-align' in document.documentElement.style;
const USER_DEFAULTS: Partial<Options> = {};
const parseDataSetId = (element: HTMLElement | null): number | undefined => {
if (!element) {
return undefined;
}
return element.dataset.id ? parseInt(element.dataset.id, 10) : undefined;
};
const selectableChoiceIdentifier = '[data-choice-selectable]';
/**
* Choices
* @author Josh Johnson<josh@joshuajohnson.co.uk>
*/
class Choices {
static version: string = '__VERSION__';
static get defaults(): {
options: Partial<Options>;
allOptions: Options;
templates: Templates;
} {
return Object.preventExtensions({
get options(): Partial<Options> {
return USER_DEFAULTS;
},
get allOptions(): Options {
return DEFAULT_CONFIG;
},
get templates(): Templates {
return defaultTemplates;
},
});
}
initialised: boolean;
initialisedOK?: boolean = undefined;
config: Options;
passedElement: WrappedInput | WrappedSelect;
containerOuter: Container;
containerInner: Container;
choiceList: List;
itemList: List;
input: Input;
dropdown: Dropdown;
_elementType: PassedElementType;
_isTextElement: boolean;
_isSelectOneElement: boolean;
_isSelectMultipleElement: boolean;
_isSelectElement: boolean;
_hasNonChoicePlaceholder: boolean = false;
_canAddUserChoices: boolean;
_store: Store<Options>;
_templates: Templates;
_lastAddedChoiceId: number = 0;
_lastAddedGroupId: number = 0;
_currentValue: string;
_canSearch: boolean;
_isScrollingOnIe: boolean;
_highlightPosition: number;
_wasTap: boolean;
_isSearching: boolean;
_placeholderValue: string | null;
_baseId: string;
_direction: HTMLElement['dir'];
_idNames: {
itemChoice: string;
};
_presetChoices: (ChoiceFull | GroupFull)[];
_initialItems: string[];
_searcher: Searcher<ChoiceFull>;
_notice?: {
type: NoticeType;
text: string;
};
_docRoot: ShadowRoot | HTMLElement;
constructor(
element: string | Element | HTMLInputElement | HTMLSelectElement = '[data-choice]',
userConfig: Partial<Options> = {},
) {
const { defaults } = Choices;
this.config = {
...defaults.allOptions,
...defaults.options,
...userConfig,
} as Options;
ObjectsInConfig.forEach((key) => {
this.config[key] = {
...defaults.allOptions[key],
...defaults.options[key],
...userConfig[key],
};
});
const { config } = this;
if (!config.silent) {
this._validateConfig();
}
const docRoot = config.shadowRoot || document.documentElement;
this._docRoot = docRoot;
const passedElement = typeof element === 'string' ? docRoot.querySelector<HTMLElement>(element) : element;
if (
!passedElement ||
typeof passedElement !== 'object' ||
!(isHtmlInputElement(passedElement) || isHtmlSelectElement(passedElement))
) {
if (!passedElement && typeof element === 'string') {
throw TypeError(`Selector ${element} failed to find an element`);
}
throw TypeError(`Expected one of the following types text|select-one|select-multiple`);
}
let elementType = passedElement.type as PassedElementType;
const isText = elementType === PassedElementTypes.Text;
if (isText || config.maxItemCount !== 1) {
config.singleModeForMultiSelect = false;
}
if (config.singleModeForMultiSelect) {
elementType = PassedElementTypes.SelectMultiple;
}
const isSelectOne = elementType === PassedElementTypes.SelectOne;
const isSelectMultiple = elementType === PassedElementTypes.SelectMultiple;
const isSelect = isSelectOne || isSelectMultiple;
this._elementType = elementType;
this._isTextElement = isText;
this._isSelectOneElement = isSelectOne;
this._isSelectMultipleElement = isSelectMultiple;
this._isSelectElement = isSelectOne || isSelectMultiple;
this._canAddUserChoices = (isText && config.addItems) || (isSelect && config.addChoices);
if (typeof config.renderSelectedChoices !== 'boolean') {
config.renderSelectedChoices = config.renderSelectedChoices === 'always' || isSelectOne;
}
if (config.closeDropdownOnSelect === 'auto') {
config.closeDropdownOnSelect = isText || isSelectOne || config.singleModeForMultiSelect;
} else {
config.closeDropdownOnSelect = coerceBool(config.closeDropdownOnSelect);
}
if (config.placeholder) {
if (config.placeholderValue) {
this._hasNonChoicePlaceholder = true;
} else if (passedElement.dataset.placeholder) {
this._hasNonChoicePlaceholder = true;
config.placeholderValue = passedElement.dataset.placeholder;
}
}
if (userConfig.addItemFilter && typeof userConfig.addItemFilter !== 'function') {
const re =
userConfig.addItemFilter instanceof RegExp ? userConfig.addItemFilter : new RegExp(userConfig.addItemFilter);
config.addItemFilter = re.test.bind(re);
}
if (this._isTextElement) {
this.passedElement = new WrappedInput({
element: passedElement as HTMLInputElement,
classNames: config.classNames,
});
} else {
const selectEl = passedElement as HTMLSelectElement;
this.passedElement = new WrappedSelect({
element: selectEl,
classNames: config.classNames,
template: (data: ChoiceFull): HTMLOptionElement => this._templates.option(data),
extractPlaceholder: config.placeholder && !this._hasNonChoicePlaceholder,
});
}
this.initialised = false;
this._store = new Store(config);
this._currentValue = '';
config.searchEnabled = (!isText && config.searchEnabled) || isSelectMultiple;
this._canSearch = config.searchEnabled;
this._isScrollingOnIe = false;
this._highlightPosition = 0;
this._wasTap = true;
this._placeholderValue = this._generatePlaceholderValue();
this._baseId = generateId(passedElement, 'choices-');
/**
* setting direction in cases where it's explicitly set on passedElement
* or when calculated direction is different from the document
*/
this._direction = passedElement.dir;
if (canUseDom && !this._direction) {
const { direction: elementDirection } = window.getComputedStyle(passedElement);
const { direction: documentDirection } = window.getComputedStyle(document.documentElement);
if (elementDirection !== documentDirection) {
this._direction = elementDirection;
}
}
this._idNames = {
itemChoice: 'item-choice',
};
this._templates = defaults.templates;
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._onInput = this._onInput.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 (!config.silent) {
console.warn('Trying to initialise Choices on element already initialised', { element });
}
this.initialised = true;
this.initialisedOK = false;
return;
}
// Let's go
this.init();
// preserve the selected item list after setup for form reset
this._initialItems = this._store.items.map((choice) => choice.value);
}
init(): void {
if (this.initialised || this.initialisedOK !== undefined) {
return;
}
this._searcher = getSearcher<ChoiceFull>(this.config);
this._loadChoices();
this._createTemplates();
this._createElements();
this._createStructure();
if (
(this._isTextElement && !this.config.addItems) ||
this.passedElement.element.hasAttribute('disabled') ||
!!this.passedElement.element.closest('fieldset:disabled')
) {
this.disable();
} else {
this.enable();
this._addEventListeners();
}
// should be triggered **after** disabled state to avoid additional re-draws
this._initStore();
this.initialised = true;
this.initialisedOK = true;
const { callbackOnInit } = this.config;
// Run callback if it is a function
if (typeof callbackOnInit === 'function') {
callbackOnInit.call(this);
}
}
destroy(): void {
if (!this.initialised) {
return;
}
this._removeEventListeners();
this.passedElement.reveal();
this.containerOuter.unwrap(this.passedElement.element);
this._store._listeners = []; // prevents select/input value being wiped
this.clearStore(false);
this._stopSearch();
this._templates = Choices.defaults.templates;
this.initialised = false;
this.initialisedOK = undefined;
}
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: InputChoice, runEvent = true): this {
if (!item || !item.id) {
return this;
}
const choice = this._store.items.find((c) => c.id === item.id);
if (!choice || choice.highlighted) {
return this;
}
this._store.dispatch(highlightItem(choice, true));
if (runEvent) {
this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(choice));
}
return this;
}
unhighlightItem(item: InputChoice, runEvent = true): this {
if (!item || !item.id) {
return this;
}
const choice = this._store.items.find((c) => c.id === item.id);
if (!choice || !choice.highlighted) {
return this;
}
this._store.dispatch(highlightItem(choice, false));
if (runEvent) {
this.passedElement.triggerEvent(EventType.unhighlightItem, this._getChoiceForOutput(choice));
}
return this;
}
highlightAll(): this {
this._store.withTxn(() => {
this._store.items.forEach((item) => {
if (!item.highlighted) {
this._store.dispatch(highlightItem(item, true));
this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(item));
}
});
});
return this;
}
unhighlightAll(): this {
this._store.withTxn(() => {
this._store.items.forEach((item) => {
if (item.highlighted) {
this._store.dispatch(highlightItem(item, false));
this.passedElement.triggerEvent(EventType.highlightItem, this._getChoiceForOutput(item));
}
});
});
return this;
}
removeActiveItemsByValue(value: string): this {
this._store.withTxn(() => {
this._store.items.filter((item) => item.value === value).forEach((item) => this._removeItem(item));
});
return this;
}
removeActiveItems(excludedId?: number): this {
this._store.withTxn(() => {
this._store.items.filter(({ id }) => id !== excludedId).forEach((item) => this._removeItem(item));
});
return this;
}
removeHighlightedItems(runEvent = false): this {
this._store.withTxn(() => {
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;
}
if (preventInputFocus === undefined) {
// eslint-disable-next-line no-param-reassign
preventInputFocus = !this._canSearch;
}
requestAnimationFrame(() => {
this.dropdown.show();
const rect = this.dropdown.element.getBoundingClientRect();
this.containerOuter.open(rect.bottom, rect.height);
if (!preventInputFocus) {
this.input.focus();
}
this.passedElement.triggerEvent(EventType.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(EventType.hideDropdown);
});
return this;
}
getValue<B extends boolean = false>(valueOnly?: B): EventChoiceValueType<B> | EventChoiceValueType<B>[] {
const values = this._store.items.map((item) => {
return (valueOnly ? item.value : this._getChoiceForOutput(item)) as EventChoiceValueType<B>;
});
return this._isSelectOneElement || this.config.singleModeForMultiSelect ? values[0] : values;
}
setValue(items: string[] | InputChoice[]): this {
if (!this.initialisedOK) {
this._warnChoicesInitFailed('setValue');
return this;
}
this._store.withTxn(() => {
items.forEach((value: string | InputChoice) => {
if (value) {
this._addChoice(mapInputToChoice(value, false));
}
});
});
// @todo integrate with Store
this._searcher.reset();
return this;
}
setChoiceByValue(value: string | string[]): this {
if (!this.initialisedOK) {
this._warnChoicesInitFailed('setChoiceByValue');
return this;
}
if (this._isTextElement) {
return this;
}
this._store.withTxn(() => {
// 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));
this.unhighlightAll();
});
// @todo integrate with Store
this._searcher.reset();
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:
| (InputChoice | InputGroup)[]
| ((instance: Choices) => (InputChoice | InputGroup)[] | Promise<(InputChoice | InputGroup)[]>) = [],
value: string | null = 'value',
label: string = 'label',
replaceChoices: boolean = false,
clearSearchFlag: boolean = true,
replaceItems: boolean = false,
): this | Promise<this> {
if (!this.initialisedOK) {
this._warnChoicesInitFailed('setChoices');
return this;
}
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`);
}
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 no-promise-executor-return
return new Promise((resolve) => requestAnimationFrame(resolve))
.then(() => this._handleLoadingState(true))
.then(() => fetcher)
.then((data: InputChoice[]) =>
this.setChoices(data, value, label, replaceChoices, clearSearchFlag, replaceItems),
)
.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._store.withTxn(() => {
if (clearSearchFlag) {
this._isSearching = false;
}
// Clear choices if needed
if (replaceChoices) {
this.clearChoices(true, replaceItems);
}
const isDefaultValue = value === 'value';
const isDefaultLabel = label === 'label';
choicesArrayOrFetcher.forEach((groupOrChoice: InputGroup | InputChoice) => {
if ('choices' in groupOrChoice) {
let group = groupOrChoice;
if (!isDefaultLabel) {
group = {
...group,
label: group[label],
} as InputGroup;
}
this._addGroup(mapInputToChoice<InputGroup>(group, true));
} else {
let choice = groupOrChoice;
if (!isDefaultLabel || !isDefaultValue) {
choice = {
...choice,
value: choice[value],
label: choice[label],
} as InputChoice;
}
const choiceFull = mapInputToChoice<InputChoice>(choice, false);
this._addChoice(choiceFull);
if (choiceFull.placeholder && !this._hasNonChoicePlaceholder) {
this._placeholderValue = unwrapStringForEscaped(choiceFull.label);
}
}
});
this.unhighlightAll();
});
// @todo integrate with Store
this._searcher.reset();
return this;
}
refresh(withEvents: boolean = false, selectFirstOption: boolean = false, deselectAll: boolean = false): this {
if (!this._isSelectElement) {
if (!this.config.silent) {
console.warn('refresh method can only be used on choices backed by a <select> element');
}
return this;
}
this._store.withTxn(() => {
const choicesFromOptions = (this.passedElement as WrappedSelect).optionsAsChoices();
// Build the list of items which require preserving
const existingItems = {};
if (!deselectAll) {
this._store.items.forEach((choice) => {
if (choice.id && choice.active && choice.selected) {
existingItems[choice.value] = true;
}
});
}
this.clearStore(false);
const updateChoice = (choice: ChoiceFull): void => {
if (deselectAll) {
this._store.dispatch(removeItem(choice));
} else if (existingItems[choice.value]) {
choice.selected = true;
}
};
choicesFromOptions.forEach((groupOrChoice) => {
if ('choices' in groupOrChoice) {
groupOrChoice.choices.forEach(updateChoice);
return;
}
updateChoice(groupOrChoice);
});
/* @todo only generate add events for the added options instead of all
if (withEvents) {
items.forEach((choice) => {
if (existingItems[choice.value]) {
this.passedElement.triggerEvent(
EventType.removeItem,
this._getChoiceForEvent(choice),
);
}
});
}
*/
// load new choices & items
this._addPredefinedChoices(choicesFromOptions, selectFirstOption, withEvents);
// re-do search if required
if (this._isSearching) {
this._searchChoices(this.input.value);
}
});
return this;
}
removeChoice(value: string): this {
const choice = this._store.choices.find((c) => c.value === value);
if (!choice) {
return this;
}
this._clearNotice();
this._store.dispatch(removeChoice(choice));
// @todo integrate with Store
this._searcher.reset();
if (choice.selected) {
this.passedElement.triggerEvent(EventType.removeItem, this._getChoiceForOutput(choice));
}
return this;
}
clearChoices(clearOptions: boolean = true, clearItems: boolean = false): this {
if (clearOptions) {
if (clearItems) {
this.passedElement.element.replaceChildren('');
} else {
this.passedElement.element.querySelectorAll(':not([selected])').forEach((el): void => {
el.remove();
});
}
}
this.itemList.element.replaceChildren('');
this.choiceList.element.replaceChildren('');
this._clearNotice();
this._store.withTxn(() => {
const items = clearItems ? [] : this._store.items;
this._store.reset();
items.forEach((item: ChoiceFull): void => {
this._store.dispatch(addChoice(item));
this._store.dispatch(addItem(item));
});
});
// @todo integrate with Store
this._searcher.reset();
return this;
}
clearStore(clearOptions: boolean = true): this {
this.clearChoices(clearOptions, true);
this._stopSearch();
this._lastAddedChoiceId = 0;
this._lastAddedGroupId = 0;
return this;
}
clearInput(): this {
const shouldSetInputWidth = !this._isSelectOneElement;
this.input.clear(shouldSetInputWidth);
this._stopSearch();
return this;
}
_validateConfig(): void {
const { config } = this;
const invalidConfigOptions = diff(config, DEFAULT_CONFIG);
if (invalidConfigOptions.length) {
console.warn('Unknown config option(s) passed', invalidConfigOptions.join(', '));
}
if (config.allowHTML && config.allowHtmlUserInput) {
if (config.addItems) {
console.warn(
'Warning: allowHTML/allowHtmlUserInput/addItems all being true is strongly not recommended and may lead to XSS attacks',
);
}
if (config.addChoices) {
console.warn(
'Warning: allowHTML/allowHtmlUserInput/addChoices all being true is strongly not recommended and may lead to XSS attacks',
);
}
}
}
_render(changes: StateChangeSet = { choices: true, groups: true, items: true }): void {
if (this._store.inTxn()) {
return;
}
if (this._isSelectElement) {
if (changes.choices || changes.groups) {
this._renderChoices();
}
}
if (changes.items) {
this._renderItems();
}
}
_renderChoices(): void {
if (!this._canAddItems()) {
return; // block rendering choices if the input limit is reached.
}
const { config, _isSearching: isSearching } = this;
const { activeGroups, activeChoices } = this._store;
let renderLimit = 0;
if (isSearching && config.searchResultLimit > 0) {
renderLimit = config.searchResultLimit;
} else if (config.renderChoiceLimit > 0) {
renderLimit = config.renderChoiceLimit;
}
if (this._isSelectElement) {
const backingOptions = activeChoices.filter((choice) => !choice.element);
if (backingOptions.length) {
(this.passedElement as WrappedSelect).addOptions(backingOptions);
}
}
const fragment = document.createDocumentFragment();
const renderableChoices = (choices: ChoiceFull[]): ChoiceFull[] =>
choices.filter(
(choice) =>
!choice.placeholder && (isSearching ? !!choice.rank : config.renderSelectedChoices || !choice.selected),
);
let selectableChoices = false;
const renderChoices = (choices: ChoiceFull[], withinGroup: boolean, groupLabel?: string): void => {
if (isSearching) {
// sortByRank is used to ensure stable sorting, as scores are non-unique
// this additionally ensures fuseOptions.sortFn is not ignored
choices.sort(sortByRank);
} else if (config.shouldSort) {
choices.sort(config.sorter);
}
let choiceLimit = choices.length;
choiceLimit = !withinGroup && renderLimit && choiceLimit > renderLimit ? renderLimit : choiceLimit;
choiceLimit--;
choices.every((choice, index) => {
// choiceEl being empty signals the contents has probably significantly changed
const dropdownItem =
choice.choiceEl || this._templates.choice(config, choice, config.itemSelectText, groupLabel);
choice.choiceEl = dropdownItem;
fragment.appendChild(dropdownItem);
if (isSearching || !choice.selected) {
selectableChoices = true;
}
return index < choiceLimit;
});
};
if (activeChoices.length) {
if (config.resetScrollPosition) {
requestAnimationFrame(() => this.choiceList.scrollToTop());
}
if (!this._hasNonChoicePlaceholder && !isSearching && this._isSelectOneElement) {
// If we have a placeholder choice along with groups
renderChoices(
activeChoices.filter((choice) => choice.placeholder && !choice.group),
false,
undefined,
);
}
// If we have grouped options
if (activeGroups.length && !isSearching) {
if (config.shouldSort) {
activeGroups.sort(config.sorter);
}
// render Choices without group first, regardless of sort, otherwise they won't be distinguishable
// from the last group
renderChoices(
activeChoices.filter((choice) => !choice.placeholder && !choice.group),
false,
undefined,
);
activeGroups.forEach((group) => {
const groupChoices = renderableChoices(group.choices);
if (groupChoices.length) {
if (group.label) {
const dropdownGroup = group.groupEl || this._templates.choiceGroup(this.config, group);
group.groupEl = dropdownGroup;
dropdownGroup.remove();
fragment.appendChild(dropdownGroup);
}
renderChoices(groupChoices, true, config.appendGroupInSearch && isSearching ? group.label : undefined);
}
});
} else {
renderChoices(renderableChoices(activeChoices), false, undefined);
}
}
if (!selectableChoices && (isSearching || !fragment.children.length || !config.renderSelectedChoices)) {
if (!this._notice) {
this._notice = {
text: resolveStringFunction(isSearching ? config.noResultsText : config.noChoicesText),
type: isSearching ? NoticeTypes.noResults : NoticeTypes.noChoices,
};
}
fragment.replaceChildren('');
}
this._renderNotice(fragment);
this.choiceList.element.replaceChildren(fragment);
if (selectableChoices) {
this._highlightChoice();
}
}
_renderItems(): void {
const items = this._store.items || [];
const itemList = this.itemList.element;
const { config } = this;
const fragment: DocumentFragment = document.createDocumentFragment();
const itemFromList = (item: ChoiceFull): HTMLElement | null =>
itemList.querySelector<HTMLElement>(`[data-item][data-id="${item.id}"]`);
const addItemToFragment = (item: ChoiceFull): void => {
let el = item.itemEl;
if (el && el.parentElement) {
return;
}
el = itemFromList(item) || this._templates.item(config, item, config.removeItemButton);
item.itemEl = el;
fragment.appendChild(el);
};
// new items
items.forEach(addItemToFragment);
let addedItems = !!fragment.childNodes.length;
if (this._isSelectOneElement) {
const existingItems = itemList.children.length;
if (addedItems || existingItems > 1) {
const placeholder = itemList.querySelector<HTMLElement>(getClassNamesSelector(config.classNames.placeholder));
if (placeholder) {
placeholder.remove();
}
} else if (!addedItems && !existingItems && this._placeholderValue) {
addedItems = true;
addItemToFragment(
mapInputToChoice<InputChoice>(
{
selected: true,
value: '',
label: this._placeholderValue,
placeholder: true,
},
false,
),
);
}
}
if (addedItems) {
itemList.append(fragment);
if (config.shouldSortItems && !this._isSelectOneElement) {
items.sort(config.sorter);
// push sorting into the DOM
items.forEach((item) => {
const el = itemFromList(item);
if (el) {
el.remove();
fragment.append(el);
}
});
itemList.append(fragment);
}
}
if (this._isTextElement) {
// Update the value of the hidden input
this.passedElement.value = items.map(({ value }) => value).join(config.delimiter);
}
}
_displayNotice(text: string, type: NoticeType, openDropdown: boolean = true): void {
const oldNotice = this._notice;
if (
oldNotice &&
((oldNotice.type === type && oldNotice.text === text) ||
(oldNotice.type === NoticeTypes.addChoice &&
(type === NoticeTypes.noResults || type === NoticeTypes.noChoices)))
) {
if (openDropdown) {
this.showDropdown(true);
}
return;
}
this._clearNotice();
this._notice = text
? {
text,
type,
}
: undefined;
this._renderNotice();
if (openDropdown && text) {
this.showDropdown(true);
}
}
_clearNotice(): void {
if (!this._notice) {
return;
}
const noticeElement = this.choiceList.element.querySelector<HTMLElement>(
getClassNamesSelector(this.config.classNames.notice),
);
if (noticeElement) {
noticeElement.remove();
}
this._notice = undefined;
}
_renderNotice(fragment?: DocumentFragment): void {
const noticeConf = this._notice;
if (noticeConf) {
const notice = this._templates.notice(this.config, noticeConf.text, noticeConf.type);
if (fragment) {
fragment.append(notice);
} else {
this.choiceList.prepend(notice);
}
}
}
// eslint-disable-next-line class-methods-use-this
_getChoiceForOutput(choice: ChoiceFull, keyCode?: number): EventChoice {
return {
id: choice.id,
highlighted: choice.highlighted,
labelClass: choice.labelClass,
labelDescription: choice.labelDescription,
customProperties: choice.customProperties,
disabled: choice.disabled,
active: choice.active,
label: choice.label,
placeholder: choice.placeholder,
value: choice.value,
groupValue: choice.group ? choice.group.label : undefined,
element: choice.element,
keyCode,
};
}
_triggerChange(value): void {
if (value === undefined || value === null) {
return;
}
this.passedElement.triggerEvent(EventType.change, {
value,
});
}
_handleButtonAction(element: HTMLElement): void {
const { items } = this._store;
if (!items.length || !this.config.removeItems || !this.config.removeItemButton) {
return;
}
const id = element && parseDataSetId(element.parentElement);
const itemToRemove = id && items.find((item) => item.id === id);
if (!itemToRemove) {
return;
}
this._store.withTxn(() => {
// Remove item associated with button
this._removeItem(itemToRemove);
this._triggerChange(itemToRemove.value);
if (this._isSelectOneElement && !this._hasNonChoicePlaceholder) {
const placeholderChoice = (this.config.shouldSort ? this._store.choices.reverse() : this._store.choices).find(
(choice) => choice.placeholder,
);
if (placeholderChoice) {
this._addItem(placeholderChoice);
this.unhighlightAll();
if (placeholderChoice.value) {
this._triggerChange(placeholderChoice.value);
}
}
}
});
}
_handleItemAction(element: HTMLElement, hasShiftKey = false): void {
const { items } = this._store;
if (!items.length || !this.config.removeItems || this._isSelectOneElement) {
return;
}
const id = parseDataSetId(element);
if (!id) {
return;
}
// 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
items.forEach((item) => {
if (item.id === id && !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(element: HTMLElement): boolean {
// If we are clicking on an option
const id = parseDataSetId(element);
const choice = id && this._store.getChoiceById(id);
if (!choice || choice.disabled) {
return false;
}
const hasActiveDropdown = this.dropdown.isActive;
if (!choice.selected) {
if (!this._canAddItems()) {
return true; // causes _onEnterKey to early out
}
this._store.withTxn(() => {
this._addItem(choice, true, true);
this.clearInput();
this.unhighlightAll();
});
this._triggerChange(choice.value);
}
// We want to close the dropdown if we are dealing with a single select box
if (hasActiveDropdown && this.config.closeDropdownOnSelect) {
this.hideDropdown(true);
this.containerOuter.element.focus();
}
return true;
}
_handleBackspace(items: ChoiceFull[]): void {
const { config } = this;
if (!config.removeItems || !items.length) {
return;
}
const lastItem = items[items.length - 1];
const hasHighlightedItems = items.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 (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);
}
}
_loadChoices(): void {
const { config } = this;
if (this._isTextElement) {
// Assign preset items from passed object first
this._presetChoices = config.items.map((e: InputChoice | string) => mapInputToChoice(e, false));
// Add any values passed from attribute
if (this.passedElement.value) {
const elementItems: ChoiceFull[] = this.passedElement.value
.split(config.delimiter)
.map((e: string) => mapInputToChoice<string>(e, false, this.config.allowHtmlUserInput));
this._presetChoices = this._presetChoices.concat(elementItems);
}
this._presetChoices.forEach((choice: ChoiceFull) => {
choice.selected = true;
});
} else if (this._isSelectElement) {
// Assign preset choices from passed object
this._presetChoices = config.choices.map((e: InputChoice) => mapInputToChoice(e, true));
// Create array of choices from option elements
const choicesFromOptions = (this.passedElement as WrappedSelect).optionsAsChoices();
if (choicesFromOptions) {
this._presetChoices.push(...choicesFromOptions);
}
}
}
_handleLoadingState(setLoading = true): void {
const el = this.itemList.element;
if (setLoading) {
this.disable();
this.containerOuter.addLoadingState();
if (this._isSelectOneElement) {
el.replaceChildren(this._templates.placeholder(this.config, this.config.loadingText));
} else {
this.input.placeholder = this.config.loadingText;
}
} else {
this.enable();
this.containerOuter.removeLoadingState();
if (this._isSelectOneElement) {
el.replaceChildren('');
this._render();
} else {
this.input.placeholder = this._placeholderValue || '';
}
}
}
_handleSearch(value?: string): void {
if (!this.input.isFocussed) {
return;
}
// Check that we have a value to search and the input was an alphanumeric character
if (value !== null && typeof value !== 'undefined' && value.length >= this.config.searchFloor) {
const resultCount = this.config.searchChoices ? this._searchChoices(value) : 0;
if (resultCount !== null) {
// Trigger search event
this.passedElement.triggerEvent(EventType.search, {
value,
resultCount,
});
}
} else if (this._store.choices.some((option) => !option.active)) {
this._stopSearch();
}
}
_canAddItems(): boolean {
const { config } = this;
const { maxItemCount, maxItemText } = config;
if (!config.singleModeForMultiSelect && maxItemCount > 0 && maxItemCount <= this._store.items.length) {
this.choiceList.element.replaceChildren('');
this._notice = undefined;
this._displayNotice(
typeof maxItemText === 'function' ? maxItemText(maxItemCount) : maxItemText,
NoticeTypes.addChoice,
);
return false;
}
if (this._notice && this._notice.type === NoticeTypes.addChoice) {
this._clearNotice();
}
return true;
}
_canCreateItem(value: string): boolean {
const { config } = this;
let canAddItem = true;
let notice = '';
if (canAddItem && typeof config.addItemFilter === 'function' && !config.addItemFilter(value)) {
canAddItem = false;
notice = resolveNoticeFunction(config.customAddItemText, value);
}
if (canAddItem) {
const foundChoice = this._store.choices.find((choice) => config.valueComparer(choice.value, value));
if (foundChoice) {
if (this._isSelectElement) {
// for exact matches, do not prompt to add it as a custom choice
this._displayNotice('', NoticeTypes.addChoice);
return false;
}
if (!config.duplicateItemsAllowed) {
canAddItem = false;
notice = resolveNoticeFunction(config.uniqueItemText, value);
}
}
}
if (canAddItem) {
notice = resolveNoticeFunction(config.addItemText, value);
}
if (notice) {
this._displayNotice(notice, NoticeTypes.addChoice);
}
return canAddItem;
}
_searchChoices(value: string): number | null {
const newValue = value.trim().replace(/\s{2,}/, ' ');
// signal input didn't change search
if (!newValue.length || newValue === this._currentValue) {
return null;
}
const searcher = this._searcher;
if (searcher.isEmptyIndex()) {
searcher.index(this._store.searchableChoices);
}
// If new value matches the desired length and is not the same as the current value with a space
const results = searcher.search(newValue);
this._currentValue = newValue;
this._highlightPosition = 0;
this._isSearching = true;
const notice = this._notice;
const noticeType = notice && notice.type;
if (noticeType !== NoticeTypes.addChoice) {
if (!results.length) {
this._displayNotice(resolveStringFunction(this.config.noResultsText), NoticeTypes.noResults);
} else {
this._clearNotice();
}
}
this._store.dispatch(filterChoices(results));
return results.length;
}
_stopSearch(): void {
if (this._isSearching) {
this._currentValue = '';
this._isSearching = false;
this._clearNotice();
this._store.dispatch(activateChoices(true));
this.passedElement.triggerEvent(EventType.search, {
value: '',
resultCount: 0,
});
}
}
_addEventListeners(): void {
const documentElement = this._docRoot;
const outerElement = this.containerOuter.element;
const inputElement = this.input.element;
// capture events - can cancel event processing or propagation
documentElement.addEventListener('touchend', this._onTouchEnd, true);
outerElement.addEventListener('keydown', this._onKeyDown, true);
outerElement.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) {
outerElement.addEventListener('focus', this._onFocus, {
passive: true,
});
outerElement.addEventListener('blur', this._onBlur, {
passive: true,
});
}
inputElement.addEventListener('keyup', this._onKeyUp, {
passive: true,
});
inputElement.addEventListener('input', this._onInput, {
passive: true,
});
inputElement.addEventListener('focus', this._onFocus, {
passive: true,
});
inputElement.addEventListener('blur', this._onBlur, {
passive: true,
});
if (inputElement.form) {
inputElement.form.addEventListener('reset', this._onFormReset, {
passive: true,
});
}
this.input.addEventListeners();
}
_removeEventListeners(): void {
const documentElement = this._docRoot;
const outerElement = this.containerOuter.element;
const inputElement = this.input.element;
documentElement.removeEventListener('touchend', this._onTouchEnd, true);
outerElement.removeEventListener('keydown', this._onKeyDown, true);
outerElement.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) {
outerElement.removeEventListener('focus', this._onFocus);
outerElement.removeEventListener('blur', this._onBlur);
}
inputElement.removeEventListener('keyup', this._onKeyUp);
inputElement.removeEventListener('input', this._onInput);
inputElement.removeEventListener('focus', this._onFocus);
inputElement.removeEventListener('blur', this._onBlur);
if (inputElement.form) {
inputElement.form.removeEventListener('reset', this._onFormReset);
}
this.input.removeEventListeners();
}
_onKeyDown(event: KeyboardEvent): void {
const { keyCode } = event;
const hasActiveDropdown = this.dropdown.isActive;
/*
See:
https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF - UTF-16 surrogate pairs
https://stackoverflow.com/a/70866532 - "Unidentified" for mobile
http://www.unicode.org/versions/Unicode5.2.0/ch16.pdf#G19635 - U+FFFF is reserved (Section 16.7)
Logic: when a key event is sent, `event.key` represents its printable value _or_ one
of a large list of special values indicating meta keys/functionality. In addition,
key events for compose functionality contain a value of `Dead` when mid-composition.
I can't quite verify it, but non-English IMEs may also be able to generate key codes
for code points in the surrogate-pair range, which could potentially be seen as having
key.length > 1. Since `Fn` is one of the special keys, we can't distinguish by that
alone.
Here, key.length === 1 means we know for sure the input was printable and not a special
`key` value. When the length is greater than 1, it could be either a printable surrogate
pair or a special `key` value. We can tell the difference by checking if the _character
code_ value (not code point!) is in the "surrogate pair" range or not.
We don't use .codePointAt because an invalid code point would return 65535, which wouldn't
pass the >= 0x10000 check we would otherwise use.
> ...The Unicode Standard sets aside 66 noncharacter code points. The last two code points
> of each plane are noncharacters: U+FFFE and U+FFFF on the BMP...
*/
const wasPrintableChar =
event.key.length === 1 ||
(event.key.length === 2 && event.key.charCodeAt(0) >= 0xd800) ||
event.key === 'Unidentified';
/*
We do not show the dropdown if focusing out with esc or navigating through input fields.
An activated search can still be opened with any other key.
*/
if (
!this._isTextElement &&
!hasActiveDropdown &&
keyCode !== KeyCodeMap.ESC_KEY &&
keyCode !== KeyCodeMap.TAB_KEY &&
keyCode !== KeyCodeMap.SHIFT_KEY
) {
this.showDropdown();
if (!this.input.isFocussed && wasPrintableChar) {
/*
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.v