UNPKG

choices.js

Version:

A vanilla JS customisable text input/select box plugin

1,645 lines (1,373 loc) 71.3 kB
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