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, sanitise, sortByRank, strToEl, } 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 = {}; 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 */ class Choices { static version: string = '__VERSION__'; static get defaults(): { options: Partial; allOptions: Options; templates: Templates; } { return Object.preventExtensions({ get options(): Partial { 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; _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; _notice?: { type: NoticeType; text: string; }; _docRoot: ShadowRoot | HTMLElement; constructor( element: string | Element | HTMLInputElement | HTMLSelectElement = '[data-choice]', userConfig: Partial = {}, ) { 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(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(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; } requestAnimationFrame(() => { this.dropdown.show(); const rect = this.dropdown.element.getBoundingClientRect(); this.containerOuter.open(rect.bottom, rect.height); if (!preventInputFocus && this._canSearch) { 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(valueOnly?: B): EventChoiceValueType | EventChoiceValueType[] { const values = this._store.items.map((item) => { return (valueOnly ? item.value : this._getChoiceForOutput(item)) as EventChoiceValueType; }); 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, ): this | Promise { 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`); } // Clear choices if needed if (replaceChoices) { this.clearChoices(); } if (typeof choicesArrayOrFetcher === 'function') { // it's a choices fetcher function const fetcher = choicesArrayOrFetcher(this); if (typeof Promise === 'function' && fetcher instanceof Promise) { // that's a promise // eslint-disable-next-line 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)) .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; } 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(group, true)); } else { let choice = groupOrChoice; if (!isDefaultLabel || !isDefaultValue) { choice = { ...choice, value: choice[value], label: choice[label], } as InputChoice; } this._addChoice(mapInputToChoice(choice, false)); } }); 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