UNPKG

@grigorov-it/mazer

Version:

Free and Open-source Bootstrap 5 Admin Dashboard Template and Landing Page

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