Choices/src/scripts/choices.ts

2350 lines
69 KiB
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,
getChoiceForOutput,
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, 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, 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, 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, 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 : 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, 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;
const renderLimit = isSearching ? config.searchResultLimit : 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 > 0 && 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);
}
}
}
/**
* @deprecated Use utils.getChoiceForOutput
*/
// eslint-disable-next-line class-methods-use-this
_getChoiceForOutput(choice: ChoiceFull, keyCode?: number): EventChoice {
return getChoiceForOutput(choice, 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, undefined);
}
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, undefined);
}
}
}
if (canAddItem) {
notice = resolveNoticeFunction(config.addItemText, value, undefined);
}
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.value += event.key;
// browsers interpret a space as pagedown
if (event.key === ' ') {
event.preventDefault();
}
}
}
switch (keyCode) {
case KeyCodeMap.A_KEY:
return this._onSelectKey(event, this.itemList.element.hasChildNodes());
case KeyCodeMap.ENTER_KEY:
return this._onEnterKey(event, hasActiveDropdown);
case KeyCodeMap.ESC_KEY:
return this._onEscapeKey(event, hasActiveDropdown);
case KeyCodeMap.UP_KEY:
case KeyCodeMap.PAGE_UP_KEY:
case KeyCodeMap.DOWN_KEY:
case KeyCodeMap.PAGE_DOWN_KEY:
return this._onDirectionKey(event, hasActiveDropdown);
case KeyCodeMap.DELETE_KEY:
case KeyCodeMap.BACK_KEY:
return this._onDeleteKey(event, this._store.items, this.input.isFocussed);
default:
}
}
_onKeyUp(/* event: KeyboardEvent */): void {
this._canSearch = this.config.searchEnabled;
}
_onInput(/* event: InputEvent */): void {
const { value } = this.input;
if (!value) {
if (this._isTextElement) {
this.hideDropdown(true);
} else {
this._stopSearch();
}
return;
}
if (!this._canAddItems()) {
return;
}
if (this._canSearch) {
// do the search even if the entered text can not be added
this._handleSearch(value);
}
if (!this._canAddUserChoices) {
return;
}
// determine if a notice needs to be displayed for why a search result can't be added
this._canCreateItem(value);
if (this._isSelectElement) {
this._highlightPosition = 0; // reset to select the notice and/or exact match
this._highlightChoice();
}
}
_onSelectKey(event: KeyboardEvent, hasItems: boolean): void {
// If CTRL + A or CMD + A have been pressed and there are items to select
if ((event.ctrlKey || event.metaKey) && hasItems) {
this._canSearch = false;
const shouldHightlightAll =
this.config.removeItems && !this.input.value && this.input.element === document.activeElement;
if (shouldHightlightAll) {
this.highlightAll();
}
}
}
_onEnterKey(event: KeyboardEvent, hasActiveDropdown: boolean): void {
const { value } = this.input;
const target = event.target as HTMLElement | null;
event.preventDefault();
if (target && target.hasAttribute('data-button')) {
this._handleButtonAction(target);
return;
}
if (!hasActiveDropdown) {
if (this._isSelectElement || this._notice) {
this.showDropdown();
}
return;
}
const highlightedChoice = this.dropdown.element.querySelector<HTMLElement>(
getClassNamesSelector(this.config.classNames.highlightedState),
);
if (highlightedChoice && this._handleChoiceAction(highlightedChoice)) {
return;
}
if (!target || !value) {
this.hideDropdown(true);
return;
}
if (!this._canAddItems()) {
return;
}
let addedItem = false;
this._store.withTxn(() => {
addedItem = this._findAndSelectChoiceByValue(value, true);
if (!addedItem) {
if (!this._canAddUserChoices) {
return;
}
if (!this._canCreateItem(value)) {
return;
}
this._addChoice(mapInputToChoice<string>(value, false, this.config.allowHtmlUserInput), true, true);
addedItem = true;
}
this.clearInput();
this.unhighlightAll();
});
if (!addedItem) {
return;
}
this._triggerChange(value);
if (this.config.closeDropdownOnSelect) {
this.hideDropdown(true);
}
}
_onEscapeKey(event: KeyboardEvent, hasActiveDropdown: boolean): void {
if (hasActiveDropdown) {
event.stopPropagation();
this.hideDropdown(true);
this._stopSearch();
this.containerOuter.element.focus();
}
}
_onDirectionKey(event: KeyboardEvent, hasActiveDropdown: boolean): void {
const { keyCode } = event;
// If up or down key is pressed, traverse through options
if (hasActiveDropdown || this._isSelectOneElement) {
this.showDropdown();
this._canSearch = false;
const directionInt = keyCode === KeyCodeMap.DOWN_KEY || keyCode === KeyCodeMap.PAGE_DOWN_KEY ? 1 : -1;
const skipKey = event.metaKey || keyCode === KeyCodeMap.PAGE_DOWN_KEY || keyCode === KeyCodeMap.PAGE_UP_KEY;
let nextEl: HTMLElement | null;
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<HTMLElement>(
getClassNamesSelector(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, items: ChoiceFull[], hasFocusedInput: boolean): void {
// If backspace or delete key is pressed and the input has no value
if (!this._isSelectOneElement && !(event.target as HTMLInputElement).value && hasFocusedInput) {
this._handleBackspace(items);
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;
this._isScrollingOnIe =
this._direction === 'ltr' ? event.offsetX >= firstChoice.offsetWidth : event.offsetX < firstChoice.offsetLeft;
}
if (target === this.input.element) {
return;
}
const item = target.closest('[data-button],[data-item],[data-choice]');
if (item instanceof HTMLElement) {
if ('button' in item.dataset) {
this._handleButtonAction(item);
} else if ('item' in item.dataset) {
this._handleItemAction(item, event.shiftKey);
} else if ('choice' in item.dataset) {
this._handleChoiceAction(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 { containerOuter } = this;
const clickWasWithinContainer = containerOuter.element.contains(target as Node);
if (clickWasWithinContainer) {
if (!this.dropdown.isActive && !containerOuter.isDisabled) {
if (this._isTextElement) {
if (document.activeElement !== this.input.element) {
this.input.focus();
}
} else {
this.showDropdown();
containerOuter.element.focus();
}
} else if (
this._isSelectOneElement &&
target !== this.input.element &&
!this.dropdown.element.contains(target as Node)
) {
this.hideDropdown();
}
} else {
containerOuter.removeFocusState();
this.hideDropdown(true);
this.unhighlightAll();
}
}
_onFocus({ target }: Pick<FocusEvent, 'target'>): void {
const { containerOuter } = this;
const focusWasWithinContainer = target && containerOuter.element.contains(target as Node);
if (!focusWasWithinContainer) {
return;
}
const targetIsInput = target === this.input.element;
if (this._isTextElement) {
if (targetIsInput) {
containerOuter.addFocusState();
}
} else if (this._isSelectMultipleElement) {
if (targetIsInput) {
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
containerOuter.addFocusState();
}
} else {
containerOuter.addFocusState();
if (targetIsInput) {
this.showDropdown(true);
}
}
}
_onBlur({ target }: Pick<FocusEvent, 'target'>): void {
const { containerOuter } = this;
const blurWasWithinContainer = target && containerOuter.element.contains(target as Node);
if (blurWasWithinContainer && !this._isScrollingOnIe) {
if (target === this.input.element) {
containerOuter.removeFocusState();
this.hideDropdown(true);
if (this._isTextElement || this._isSelectMultipleElement) {
this.unhighlightAll();
}
} else if (target === this.containerOuter.element) {
// Remove the focus state when the past outerContainer was the target
containerOuter.removeFocusState();
// Also close the dropdown if search is disabled
if (!this.config.searchEnabled) {
this.hideDropdown(true);
}
}
} 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.withTxn(() => {
this.clearInput();
this.hideDropdown();
this.refresh(false, false, true);
if (this._initialItems.length) {
this.setChoiceByValue(this._initialItems);
}
});
}
_highlightChoice(el: HTMLElement | null = null): void {
const choices = Array.from(this.dropdown.element.querySelectorAll<HTMLElement>(selectableChoiceIdentifier));
if (!choices.length) {
return;
}
let passedEl = el;
const { highlightedState } = this.config.classNames;
const highlightedChoices = Array.from(
this.dropdown.element.querySelectorAll<HTMLElement>(getClassNamesSelector(highlightedState)),
);
// Remove any highlighted choices
highlightedChoices.forEach((choice) => {
removeClassesFromElement(choice, highlightedState);
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];
}
}
addClassesToElement(passedEl, highlightedState);
passedEl.setAttribute('aria-selected', 'true');
this.passedElement.triggerEvent(EventType.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(item: ChoiceFull, withEvents: boolean = true, userTriggered = false): void {
if (!item.id) {
throw new TypeError('item.id must be set before _addItem is called for a choice/item');
}
if (this.config.singleModeForMultiSelect || this._isSelectOneElement) {
this.removeActiveItems(item.id);
}
this._store.dispatch(addItem(item));
if (withEvents) {
const eventChoice = getChoiceForOutput(item);
this.passedElement.triggerEvent(EventType.addItem, eventChoice);
if (userTriggered) {
this.passedElement.triggerEvent(EventType.choice, eventChoice);
}
}
}
_removeItem(item: ChoiceFull): void {
if (!item.id) {
return;
}
this._store.dispatch(removeItem(item));
const notice = this._notice;
if (notice && notice.type === NoticeTypes.noChoices) {
this._clearNotice();
}
this.passedElement.triggerEvent(EventType.removeItem, getChoiceForOutput(item));
}
_addChoice(choice: ChoiceFull, withEvents: boolean = true, userTriggered = false): void {
if (choice.id) {
throw new TypeError('Can not re-add a choice which has already been added');
}
const { config } = this;
if (!config.duplicateItemsAllowed && this._store.choices.find((c) => config.valueComparer(c.value, choice.value))) {
return;
}
// Generate unique id, in-place update is required so chaining _addItem works as expected
this._lastAddedChoiceId++;
choice.id = this._lastAddedChoiceId;
choice.elementId = `${this._baseId}-${this._idNames.itemChoice}-${choice.id}`;
const { prependValue, appendValue } = config;
if (prependValue) {
choice.value = prependValue + choice.value;
}
if (appendValue) {
choice.value += appendValue.toString();
}
if ((prependValue || appendValue) && choice.element) {
(choice.element as HTMLOptionElement).value = choice.value;
}
this._clearNotice();
this._store.dispatch(addChoice(choice));
if (choice.selected) {
this._addItem(choice, withEvents, userTriggered);
}
}
_addGroup(group: GroupFull, withEvents: boolean = true): void {
if (group.id) {
throw new TypeError('Can not re-add a group which has already been added');
}
this._store.dispatch(addGroup(group));
if (!group.choices) {
return;
}
// add unique id for the group(s), and do not store the full list of choices in this group
this._lastAddedGroupId++;
group.id = this._lastAddedGroupId;
group.choices.forEach((item: ChoiceFull) => {
item.group = group;
if (group.disabled) {
item.disabled = true;
}
this._addChoice(item, withEvents);
});
}
_createTemplates(): void {
const { callbackOnCreateTemplates } = this.config;
let userTemplates: Partial<Templates> = {};
if (typeof callbackOnCreateTemplates === 'function') {
userTemplates = callbackOnCreateTemplates.call(this, strToEl, escapeForTemplate, getClassNames);
}
const templating: Partial<Templates> = {};
Object.keys(this._templates).forEach((name) => {
if (name in userTemplates) {
templating[name] = userTemplates[name].bind(this);
} else {
templating[name] = this._templates[name].bind(this);
}
});
this._templates = templating as Templates;
}
_createElements(): void {
const templating = this._templates;
const { config, _isSelectOneElement: isSelectOneElement } = this;
const { position, classNames } = config;
const elementType = this._elementType;
this.containerOuter = new Container({
element: templating.containerOuter(
config,
this._direction,
this._isSelectElement,
isSelectOneElement,
config.searchEnabled,
elementType,
config.labelId,
),
classNames,
type: elementType,
position,
});
this.containerInner = new Container({
element: templating.containerInner(config),
classNames,
type: elementType,
position,
});
this.input = new Input({
element: templating.input(config, this._placeholderValue),
classNames,
type: elementType,
preventPaste: !config.paste,
});
this.choiceList = new List({
element: templating.choiceList(config, isSelectOneElement),
});
this.itemList = new List({
element: templating.itemList(config, isSelectOneElement),
});
this.dropdown = new Dropdown({
element: templating.dropdown(config),
classNames,
type: elementType,
});
}
_createStructure(): void {
const { containerInner, containerOuter, passedElement } = this;
const dropdownElement = this.dropdown.element;
// Hide original element
passedElement.conceal();
// Wrap input in container preserving DOM ordering
containerInner.wrap(passedElement.element);
// Wrapper inner container with outer container
containerOuter.wrap(containerInner.element);
if (this._isSelectOneElement) {
this.input.placeholder = this.config.searchPlaceholderValue || '';
} else {
if (this._placeholderValue) {
this.input.placeholder = this._placeholderValue;
}
this.input.setWidth();
}
containerOuter.element.appendChild(containerInner.element);
containerOuter.element.appendChild(dropdownElement);
containerInner.element.appendChild(this.itemList.element);
dropdownElement.appendChild(this.choiceList.element);
if (!this._isSelectOneElement) {
containerInner.element.appendChild(this.input.element);
} else if (this.config.searchEnabled) {
dropdownElement.insertBefore(this.input.element, dropdownElement.firstChild);
}
this._highlightPosition = 0;
this._isSearching = false;
}
_initStore(): void {
this._store.subscribe(this._render).withTxn(() => {
this._addPredefinedChoices(
this._presetChoices,
this._isSelectOneElement && !this._hasNonChoicePlaceholder,
false,
);
});
if (!this._store.choices.length || (this._isSelectOneElement && this._hasNonChoicePlaceholder)) {
this._render();
}
}
_addPredefinedChoices(
choices: (ChoiceFull | GroupFull)[],
selectFirstOption: boolean = false,
withEvents: boolean = true,
): void {
if (selectFirstOption) {
/**
* 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 noSelectedChoices = choices.findIndex((choice: ChoiceFull) => choice.selected) === -1;
if (noSelectedChoices) {
choices.some((choice) => {
if (choice.disabled || 'choices' in choice) {
return false;
}
choice.selected = true;
return true;
});
}
}
choices.forEach((item) => {
if ('choices' in item) {
if (this._isSelectElement) {
this._addGroup(item, withEvents);
}
} else {
this._addChoice(item, withEvents);
}
});
}
_findAndSelectChoiceByValue(value: string, userTriggered: boolean = false): boolean {
// Check 'value' property exists and the choice isn't already selected
const foundChoice = this._store.choices.find((choice) => this.config.valueComparer(choice.value, value));
if (foundChoice && !foundChoice.disabled && !foundChoice.selected) {
this._addItem(foundChoice, true, userTriggered);
return true;
}
return false;
}
_generatePlaceholderValue(): string | null {
const { config } = this;
if (!config.placeholder) {
return null;
}
if (this._hasNonChoicePlaceholder) {
return config.placeholderValue;
}
if (this._isSelectElement) {
const { placeholderOption } = this.passedElement as WrappedSelect;
return placeholderOption ? placeholderOption.text : null;
}
return null;
}
_warnChoicesInitFailed(caller: string): void {
if (this.config.silent) {
return;
}
if (!this.initialised) {
throw new TypeError(`${caller} called on a non-initialised instance of Choices`);
} else if (!this.initialisedOK) {
throw new TypeError(`${caller} called for an element which has multiple instances of Choices initialised on it`);
}
}
}
export default Choices;