UNPKG

multiple-select-vanilla

Version:

This lib allows you to select multiple elements with checkboxes

1,428 lines (1,254 loc) 68.5 kB
/** * @author zhixin wen <wenzhixin2010@gmail.com> */ import Constants from './constants.js'; import type { HtmlStruct, OptGroupRowData, OptionDataObject, OptionRowData } from './models/interfaces.js'; import type { MultipleSelectLocales } from './models/locale.interface.js'; import type { CloseReason, MultipleSelectOption } from './models/multipleSelectOption.interface.js'; import { BindingEventService } from './services/binding-event.service.js'; import { VirtualScroll } from './services/virtual-scroll.js'; import { compareObjects, deepCopy, findByParam, removeDiacritics, removeUndefined, setDataKeys, stripScripts } from './utils/utils.js'; import { calculateAvailableSpace, classNameToList, convertItemRowToHtml, createDomElement, emptyElement, findParent, getElementOffset, getElementSize, insertAfter, toggleElement, } from './utils/domUtils.js'; import type { HtmlElementPosition } from './utils/domUtils.js'; const OPTIONS_LIST_SELECTOR = '.ms-select-all, ul li[data-key]'; const OPTIONS_HIGHLIGHT_LIST_SELECTOR = '.ms-select-all.highlighted, ul li[data-key].highlighted'; export class MultipleSelectInstance { protected _bindEventService: BindingEventService; protected isAllSelected = false; protected isPartiallyAllSelected = false; protected fromHtml = false; protected choiceElm!: HTMLButtonElement; protected selectClearElm?: HTMLDivElement | null; protected closeElm?: HTMLElement | null; protected clearSearchIconElm?: HTMLElement | null; protected filterText = ''; protected updateData: any[] = []; protected data?: Array<OptionRowData | OptGroupRowData> = []; protected dataTotal?: any; protected dropElm?: HTMLDivElement; protected okButtonElm?: HTMLButtonElement; protected filterParentElm?: HTMLDivElement | null; protected lastFocusedItemKey = ''; protected lastMouseOverPosition = ''; protected ulElm?: HTMLUListElement | null; protected parentElm!: HTMLDivElement; protected labelElm?: HTMLLabelElement | null; protected selectAllParentElm?: HTMLDivElement | null; protected selectAllElm?: HTMLInputElement | null; protected searchInputElm?: HTMLInputElement | null; protected selectGroupElms?: NodeListOf<HTMLInputElement>; protected selectItemElms?: NodeListOf<HTMLInputElement>; protected noResultsElm?: HTMLDivElement | null; protected options: MultipleSelectOption; protected selectAllName = ''; protected selectGroupName = ''; protected selectItemName = ''; protected scrolledByMouse = false; protected openDelayTimer?: number; protected updateDataStart?: number; protected updateDataEnd?: number; protected virtualScroll?: VirtualScroll | null; protected _currentHighlightIndex = -1; protected _currentSelectedElm?: HTMLLIElement | HTMLDivElement; protected isMoveUpRecalcRequired = false; locales = {} as MultipleSelectLocales; get isRenderAsHtml() { return this.options.renderOptionLabelAsHtml || this.options.useSelectOptionLabelToHtml; } constructor( protected elm: HTMLSelectElement, options?: Partial<Omit<MultipleSelectOption, 'onHardDestroy' | 'onAfterHardDestroy'>>, ) { this.options = Object.assign({}, Constants.DEFAULTS, this.elm.dataset, options) as MultipleSelectOption; this._bindEventService = new BindingEventService({ distinctEvent: true }); } init() { this.initLocale(); this.initContainer(); this.initData(); this.initSelected(true); this.initFilter(); this.initDrop(); this.initView(); this.options.onAfterCreate(); } /** * destroy the element, if a hard destroy is enabled then we'll also nullify it on the multipleSelect instance array. * When a soft destroy is called, we'll only remove it from the DOM but we'll keep all multipleSelect instances */ destroy(hardDestroy = true) { if (this.elm && this.parentElm) { this.options.onDestroy({ hardDestroy }); if (hardDestroy) { this.options.onHardDestroy(); } if (this.elm.parentElement && this.parentElm.parentElement) { this.elm.parentElement.insertBefore(this.elm, this.parentElm.parentElement!.firstChild); } this.elm.classList.remove('ms-offscreen'); this._bindEventService.unbindAll(); this.virtualScroll?.destroy(); this.dropElm?.remove(); this.dropElm = undefined; this.parentElm.parentNode?.removeChild(this.parentElm); if (this.fromHtml) { delete this.options.data; this.fromHtml = false; } this.options.onAfterDestroy({ hardDestroy }); // on a hard destroy, we will also nullify all variables & call event so that _multipleSelect can also nullify its own instance if (hardDestroy) { this.options.onAfterHardDestroy?.(); Object.keys(this.options).forEach(o => delete (this as any)[o]); } } } protected initLocale() { if (this.options.locale) { if (typeof this.options.locale === 'object') { Object.assign(this.options, this.options.locale); return; } const locales = window.multipleSelect.locales; const parts = this.options.locale.split(/-|_/); parts[0] = parts[0].toLowerCase(); if (parts[1]) { parts[1] = parts[1].toUpperCase(); } if (locales[this.options.locale]) { Object.assign(this.options, locales[this.options.locale]); } else if (locales[parts.join('-')]) { Object.assign(this.options, locales[parts.join('-')]); } else if (locales[parts[0]]) { Object.assign(this.options, locales[parts[0]]); } else { throw new Error(`[multiple-select-vanilla] invalid locales "${this.options.locale}", make sure to import it before using it`); } } } protected initContainer() { const name = this.elm.getAttribute('name') || this.options.name || ''; if (this.options.classes) { this.elm.classList.add(this.options.classes); } if (this.options.classPrefix) { this.elm.classList.add(this.options.classPrefix); if (this.options.size) { this.elm.classList.add(`${this.options.classPrefix}-${this.options.size}`); } } // hide select element this.elm.style.display = 'none'; // label element this.labelElm = this.elm.closest('label'); if (!this.labelElm && this.elm.id) { this.labelElm = document.createElement('label'); this.labelElm.htmlFor = this.elm.id; } if (this.labelElm?.querySelector('input')) { this.labelElm = null; } // single or multiple if (typeof this.options.single === 'undefined') { this.options.single = !this.elm.multiple; } // restore class and title from select element this.parentElm = createDomElement('div', { className: classNameToList(`ms-parent ${this.elm.className || ''} ${this.options.classes}`).join(' '), dataset: { test: 'sel' }, }); if (this.options.darkMode) { this.parentElm.classList.add('ms-dark-mode'); } // add tooltip title only when provided const parentTitle = this.elm.getAttribute('title') || ''; if (parentTitle) { this.parentElm.title = parentTitle; } // add placeholder to choice button this.options.placeholder = this.options.placeholder || this.elm.getAttribute('placeholder') || ''; this.choiceElm = createDomElement('button', { className: 'ms-choice', type: 'button' }, this.parentElm); if (this.options.labelId) { this.choiceElm.id = this.options.labelId; this.choiceElm.setAttribute('aria-labelledby', this.options.labelId); } this.choiceElm.appendChild(createDomElement('span', { className: 'ms-placeholder', textContent: this.options.placeholder })); if (this.options.showClear) { this.selectClearElm = createDomElement('div', { className: 'ms-icon ms-icon-close' }); this.selectClearElm.style.display = 'none'; // don't show unless filled this.choiceElm.appendChild(this.selectClearElm); } this.choiceElm.appendChild(createDomElement('div', { className: 'ms-icon ms-icon-caret' })); // default position is bottom this.dropElm = createDomElement('div', { className: `ms-drop ${this.options.position}`, ariaExpanded: 'false' }, this.parentElm); if (this.options.darkMode) { this.dropElm.classList.add('ms-dark-mode'); } // add data-name attribute when name option is defined if (name) { this.dropElm.dataset.name = name; } // add [data-test="name"] attribute to both ms-parent & ms-drop const dataTest = this.elm.getAttribute('data-test') || this.options.dataTest; if (dataTest) { this.parentElm.dataset.test = dataTest; this.dropElm.dataset.test = dataTest; } this.closeElm = this.choiceElm.querySelector('.ms-icon-close'); if (this.options.dropWidth) { this.dropElm.style.width = typeof this.options.dropWidth === 'string' ? this.options.dropWidth : `${this.options.dropWidth}px`; } insertAfter(this.elm, this.parentElm); if (this.elm.disabled) { this.choiceElm.classList.add('disabled'); this.choiceElm.disabled = true; } this.selectAllName = `selectAll${name}`; this.selectGroupName = `selectGroup${name}`; this.selectItemName = `selectItem${name}`; if (!this.options.keepOpen) { this._bindEventService.unbindAll('body-click'); this._bindEventService.bind( document.body, 'click', ((e: MouseEvent & { target: HTMLElement }) => { if (this.getEventTarget(e) === this.choiceElm || findParent(this.getEventTarget(e), '.ms-choice') === this.choiceElm) { return; } if ( (this.getEventTarget(e) === this.dropElm || (findParent(this.getEventTarget(e), '.ms-drop') !== this.dropElm && this.getEventTarget(e) !== this.elm)) && this.options.isOpen ) { this.close('body.click'); } }) as EventListener, undefined, 'body-click', ); } } protected initData() { const data: Array<OptionRowData> = []; if (this.options.data) { if (Array.isArray(this.options.data)) { this.data = this.options.data.map((it: any) => { if (typeof it === 'string' || typeof it === 'number') { return { text: it, value: it, }; } return it; }); } else if (typeof this.options.data === 'object') { for (const [value, text] of Object.entries(this.options.data as OptionDataObject)) { data.push({ value, text: `${text}`, }); } this.data = data; } } else { this.elm.childNodes.forEach(elm => { const row = this.initRow(elm as HTMLOptionElement); if (row) { data.push(row as OptionRowData); } }); this.options.data = data; this.data = data; this.fromHtml = true; } this.dataTotal = setDataKeys(this.data || []); } protected initRow(elm: HTMLOptionElement, groupDisabled?: boolean) { const row = {} as OptionRowData | OptGroupRowData; if (elm.tagName?.toLowerCase() === 'option') { row.type = 'option'; (row as OptionRowData).text = this.options.textTemplate(elm); row.value = elm.value; row.visible = true; row.selected = !!elm.selected; row.disabled = groupDisabled || elm.disabled; row.classes = elm.getAttribute('class') || ''; row.title = elm.getAttribute('title') || ''; if (elm.dataset.value) { row._value = elm.dataset.value; // value for object } if (Object.keys(elm.dataset).length) { row._data = elm.dataset; if (row._data.divider) { row.divider = row._data.divider; } } return row; } if (elm.tagName?.toLowerCase() === 'optgroup') { row.type = 'optgroup'; (row as OptGroupRowData).label = this.options.labelTemplate(elm); row.visible = true; row.selected = !!elm.selected; row.disabled = elm.disabled; (row as OptGroupRowData).children = []; if (Object.keys(elm.dataset).length) { row._data = elm.dataset; } elm.childNodes.forEach(childNode => { (row as OptGroupRowData).children.push(this.initRow(childNode as HTMLOptionElement, row.disabled) as OptionRowData); }); return row; } return null; } protected initDrop() { this.initList(); this.update(true); if (this.options.isOpen) { this.open(10); } if (this.options.openOnHover && this.parentElm) { this._bindEventService.bind(this.parentElm, 'mouseover', () => this.open(null)); this._bindEventService.bind(this.parentElm, 'mouseout', () => this.close('hover.mouseout')); } } protected initFilter() { this.filterText = ''; if (this.options.filter || !this.options.filterByDataLength) { return; } let length = 0; for (const option of this.data || []) { if ((option as OptGroupRowData).type === 'optgroup') { length += (option as OptGroupRowData).children.length; } else { length += 1; } } this.options.filter = length > this.options.filterByDataLength; } protected initList() { if (this.options.filter) { this.filterParentElm = createDomElement('div', { className: 'ms-search' }, this.dropElm); this.filterParentElm.appendChild( createDomElement('input', { autocomplete: 'off', autocapitalize: 'off', spellcheck: false, type: 'text', placeholder: this.options.filterPlaceholder || '🔎︎', }), ); if (this.options.showSearchClear) { this.filterParentElm.appendChild(createDomElement('span', { className: 'ms-icon ms-icon-close' })); } } if (this.options.selectAll && !this.options.single) { const selectName = this.elm.getAttribute('name') || this.options.name || ''; this.selectAllParentElm = createDomElement('div', { className: 'ms-select-all', dataset: { key: 'select_all' } }); const saLabelElm = document.createElement('label'); const saIconClass = this.isAllSelected ? 'ms-icon-check' : this.isPartiallyAllSelected ? 'ms-icon-minus' : 'ms-icon-uncheck'; const selectAllIconClass = `ms-icon ${saIconClass}`; const saIconContainerElm = createDomElement('div', { className: 'icon-checkbox-container' }, saLabelElm); createDomElement( 'input', { type: 'checkbox', ariaChecked: String(this.isAllSelected), checked: this.isAllSelected, dataset: { name: `selectAll${selectName}` }, }, saIconContainerElm, ); createDomElement('div', { className: selectAllIconClass }, saIconContainerElm); saLabelElm.appendChild(createDomElement('span', { textContent: this.formatSelectAll() })); this.selectAllParentElm.appendChild(saLabelElm); this.dropElm?.appendChild(this.selectAllParentElm); } this.ulElm = document.createElement('ul'); this.ulElm.role = 'combobox'; this.ulElm.ariaExpanded = 'false'; this.ulElm.ariaMultiSelectable = String(!this.options.single); this.dropElm?.appendChild(this.ulElm); if (this.options.showOkButton && !this.options.single) { this.okButtonElm = createDomElement( 'button', { className: 'ms-ok-button', type: 'button', textContent: this.formatOkButton() }, this.dropElm, ); } this.initListItems(); } protected initListItems(): HtmlStruct[] { let offset = 0; const rows = this.getListRows(); if (this.options.selectAll && !this.options.single) { offset = -1; } if (rows.length > Constants.BLOCK_ROWS * Constants.CLUSTER_BLOCKS) { const dropVisible = this.dropElm && this.dropElm?.style.display !== 'none'; if (!dropVisible && this.dropElm) { this.dropElm.style.left = '-10000'; this.dropElm.style.display = 'block'; this.dropElm.ariaExpanded = 'true'; } const updateDataOffset = () => { if (this.virtualScroll) { this._currentHighlightIndex = 0; this.updateDataStart = this.virtualScroll.dataStart + offset; this.updateDataEnd = this.virtualScroll.dataEnd + offset; if (this.updateDataStart < 0) { this.updateDataStart = 0; this._currentHighlightIndex = 0; } const dataLn = this.getDataLength(); if (this.updateDataEnd > dataLn) { this.updateDataEnd = dataLn; } if (this.ulElm) { if (this.isMoveUpRecalcRequired) { this.recalculateArrowMove('up'); } else if (this.virtualScroll.dataStart > this.updateDataStart) { this.recalculateArrowMove('down'); } } } }; if (this.ulElm) { if (!this.virtualScroll) { this.virtualScroll = new VirtualScroll({ rows, scrollEl: this.ulElm, contentEl: this.ulElm, sanitizer: this.options.sanitizer, callback: () => { updateDataOffset(); this.events(); }, }); } else { this.virtualScroll.reset(rows); } } updateDataOffset(); if (!dropVisible && this.dropElm) { this.dropElm.style.left = '0'; this.dropElm.style.display = 'none'; this.dropElm.ariaExpanded = 'false'; } } else { if (this.ulElm) { emptyElement(this.ulElm); rows.forEach(itemRow => this.ulElm!.appendChild(convertItemRowToHtml(itemRow))); } this.updateDataStart = 0; this.updateDataEnd = this.updateData.length; } this.events(); return rows; } protected getEventTarget(e: Event & { target: HTMLElement }): HTMLElement { if (e.composedPath) { return e.composedPath()[0] as HTMLElement; } return e.target as HTMLElement; } protected getListRows(): HtmlStruct[] { const rows: HtmlStruct[] = []; this.updateData = []; this.data?.forEach(dataRow => rows.push(...this.initListItem(dataRow))); // when infinite scroll is enabled, we'll add an empty <li> element (that will never be clickable) // so that scrolling to the last valid item will NOT automatically scroll back to the top of the list. // However scrolling by 1 more item (the last invisible item) will at that time trigger the scroll back to the top of the list if (this.options.infiniteScroll) { rows.push({ tagName: 'li', props: { className: 'ms-infinite-option', role: 'option' }, }); } // add a "No Results" option that is hidden by default rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound() } }); return rows; } protected initListItem(dataRow: OptionRowData | OptGroupRowData, level = 0): HtmlStruct[] { const title = dataRow?.title || ''; const multiple = this.options.multiple ? 'multiple' : ''; const type = this.options.single ? 'radio' : 'checkbox'; const isChecked = !!dataRow?.selected; const isSingleWithoutRadioIcon = this.options.single && !this.options.singleRadio; let classes = ''; if (!dataRow?.visible) { return []; } this.updateData.push(dataRow); if (isSingleWithoutRadioIcon) { classes = 'hide-radio '; } if (dataRow.selected) { classes += 'selected '; } if (dataRow.type === 'optgroup') { // - group option row - const htmlBlocks: HtmlStruct[] = []; let itemOrGroupBlock: HtmlStruct; if (this.options.hideOptgroupCheckboxes || this.options.single) { itemOrGroupBlock = { tagName: 'span', props: { dataset: { name: this.selectGroupName, key: dataRow._key } } }; } else { const inputCheckboxStruct: HtmlStruct = { tagName: 'input', props: { type: 'checkbox', dataset: { name: this.selectGroupName, key: dataRow._key }, checked: isChecked, disabled: dataRow.disabled, }, }; // when creating a block that has multiple selections, we'll add the icon checkbox container // otherwise it will be just the input checkbox if (isSingleWithoutRadioIcon) { itemOrGroupBlock = inputCheckboxStruct; } else { itemOrGroupBlock = { tagName: 'div', props: { className: `icon-checkbox-container${type === 'radio' ? ' radio' : ''}` }, children: [ inputCheckboxStruct, { tagName: 'div', props: { className: `ms-icon ${isChecked ? (type === 'radio' ? 'ms-icon-radio' : 'ms-icon-check') : 'ms-icon-uncheck'}` }, }, ], }; } } if (!classes.includes('hide-radio') && (this.options.hideOptgroupCheckboxes || this.options.single)) { classes += 'hide-radio '; } const spanLabelBlock: HtmlStruct = { tagName: 'span', props: {} }; this.applyAsTextOrHtmlWhenEnabled(spanLabelBlock.props, (dataRow as OptGroupRowData).label); const liBlock: HtmlStruct = { tagName: 'li', props: { className: classNameToList(`group${this.options.single || dataRow.disabled ? ' disabled' : ''} ${classes}`).join(' '), role: 'option', ariaSelected: String(isChecked), dataset: { key: dataRow._key }, }, children: [ { tagName: 'label', props: { className: classNameToList(`optgroup${this.options.single || dataRow.disabled ? ' disabled' : ''}`).join(' ') }, children: [itemOrGroupBlock, spanLabelBlock], }, ], }; const customStyleRules = this.options.cssStyler(dataRow); if (customStyleRules) { liBlock.props.style = customStyleRules; } htmlBlocks.push(liBlock); (dataRow as OptGroupRowData).children.forEach(child => htmlBlocks.push(...this.initListItem(child, 1))); return htmlBlocks; } // - regular row - classes += dataRow.classes || ''; if (level && this.options.single) { classes += `option-level-${level} `; } if (dataRow.divider) { return [{ tagName: 'li', props: { className: 'option-divider' } } as HtmlStruct]; } let liClasses = multiple || classes ? (multiple + classes).trim() : ''; if (dataRow.disabled) { liClasses += ' disabled'; } const labelClasses = `${dataRow.disabled ? 'disabled' : ''}`; const spanLabelBlock: HtmlStruct = { tagName: 'span', props: {} }; this.applyAsTextOrHtmlWhenEnabled(spanLabelBlock.props, (dataRow as OptionRowData).text); const inputBlock: HtmlStruct = { tagName: 'input', props: { type, value: encodeURI(dataRow.value as string), dataset: { key: dataRow._key, name: this.selectItemName }, checked: isChecked, disabled: !!dataRow.disabled, }, }; if (dataRow.selected) { inputBlock.attrs = { checked: 'checked' }; } const iconContainerBlock: HtmlStruct = { tagName: 'div', props: { className: `icon-checkbox-container${type === 'radio' ? ' radio' : ''}` }, children: [ inputBlock, { tagName: 'div', props: { className: `ms-icon ${inputBlock.props.checked ? (type === 'radio' ? 'ms-icon-radio' : 'ms-icon-check') : 'ms-icon-uncheck'}`, }, }, ], }; const liBlock: HtmlStruct = { tagName: 'li', props: { role: 'option', title, ariaSelected: String(isChecked), dataset: { key: dataRow._key }, }, children: [ { tagName: 'label', props: { className: labelClasses }, children: [ // add icon container when showing radio OR using multiple select isSingleWithoutRadioIcon ? inputBlock : iconContainerBlock, spanLabelBlock, ], }, ], }; if (liClasses) { liBlock.props.className = liClasses; } const customStyleRules = this.options.cssStyler(dataRow); if (customStyleRules) { liBlock.props.style = customStyleRules; } return [liBlock]; } protected initSelected(ignoreTrigger = false) { let selectedTotal = 0; for (const row of this.data || []) { if ((row as OptGroupRowData).type === 'optgroup') { const selectedCount = (row as OptGroupRowData).children.filter(child => child?.selected && !child.disabled && child.visible).length; if ((row as OptGroupRowData).children.length) { row.selected = !this.options.single && selectedCount && selectedCount === (row as OptGroupRowData).children.filter((child: any) => child && !child.disabled && child.visible && !child.divider).length; } selectedTotal += selectedCount; } else { selectedTotal += row.selected && !row.disabled && row.visible ? 1 : 0; } } this.isAllSelected = this.data?.filter((row: OptionRowData | OptGroupRowData) => { return row.selected && !row.disabled && row.visible; }).length === this.data?.filter(row => !row.disabled && row.visible && !row.divider).length; this.isPartiallyAllSelected = !this.isAllSelected && selectedTotal > 0; if (!ignoreTrigger) { if (this.isAllSelected) { this.options.onCheckAll(); } else if (selectedTotal === 0) { this.options.onUncheckAll(); } } } protected initView() { let computedWidth: number | string; if (window.getComputedStyle) { computedWidth = window.getComputedStyle(this.elm).width; if (computedWidth === 'auto') { computedWidth = getElementSize(this.dropElm, 'outer', 'width') + 20; } } else { computedWidth = getElementSize(this.elm, 'outer', 'width') + 20; } this.parentElm.style.width = `${this.options.width || computedWidth}px`; this.elm.classList.add('ms-offscreen'); } protected events() { this._bindEventService.unbindAll([ 'ok-button', 'search-input', 'select-all-checkbox', 'input-checkbox-list', 'group-checkbox-list', 'hover-highlight', 'arrow-highlight', 'option-list-scroll', ]); this.clearSearchIconElm = this.filterParentElm?.querySelector('.ms-icon-close'); this.searchInputElm = this.dropElm?.querySelector<HTMLInputElement>('.ms-search input'); this.selectAllElm = this.dropElm?.querySelector<HTMLInputElement>(`input[data-name="${this.selectAllName}"]`); this.selectGroupElms = this.dropElm?.querySelectorAll<HTMLInputElement>( `input[data-name="${this.selectGroupName}"],span[data-name="${this.selectGroupName}"]`, ); this.selectItemElms = this.dropElm?.querySelectorAll<HTMLInputElement>(`input[data-name="${this.selectItemName}"]:enabled`); this.noResultsElm = this.dropElm?.querySelector<HTMLDivElement>('.ms-no-results'); const toggleOpen = (e: MouseEvent & { target: HTMLElement }) => { e.preventDefault(); if (this.getEventTarget(e).classList.contains('ms-icon-close')) { return; } this.options.isOpen ? this.close('toggle.close') : this.open(); }; if (this.labelElm) { this._bindEventService.bind(this.labelElm, 'click', ((e: MouseEvent & { target: HTMLElement }) => { if (this.getEventTarget(e).nodeName.toLowerCase() !== 'label') { return; } toggleOpen(e); if (!this.options.filter || !this.options.isOpen) { this.focus(); } e.stopPropagation(); // Causes lost focus otherwise }) as EventListener); } this._bindEventService.bind(this.choiceElm, 'click', toggleOpen as EventListener); if (this.options.onFocus) { this._bindEventService.bind(this.choiceElm, 'focus', this.options.onFocus as EventListener); } if (this.options.onBlur) { this._bindEventService.bind(this.choiceElm, 'blur', this.options.onBlur as EventListener); } this._bindEventService.bind(this.parentElm, 'keydown', ((e: KeyboardEvent) => { if (e.code === 'Escape') { this.handleEscapeKey(); } }) as EventListener); if (this.closeElm) { this._bindEventService.bind(this.closeElm, 'click', ((e: MouseEvent) => { e.preventDefault(); this._checkAll(false, true); this.initSelected(false); this.updateSelected(); this.update(); this.options.onClear(); }) as EventListener); } if (this.clearSearchIconElm) { this._bindEventService.bind(this.clearSearchIconElm, 'click', ((e: MouseEvent) => { e.preventDefault(); if (this.searchInputElm) { this.searchInputElm.value = ''; this.searchInputElm.focus(); } // move highlight back to top of the list this._currentHighlightIndex = -1; this.moveHighlightDown(); this.filter(); this.options.onFilterClear(); }) as EventListener); } if (this.searchInputElm) { this._bindEventService.bind( this.searchInputElm, 'keydown', ((e: KeyboardEvent) => { // Ensure shift-tab causes lost focus from filter as with clicking away if (e.code === 'Tab' && e.shiftKey) { this.close('key.shift+tab'); } }) as EventListener, undefined, 'search-input', ); this._bindEventService.bind( this.searchInputElm, 'keyup', ((e: KeyboardEvent) => { // enter or space // Avoid selecting/deselecting if no choices made if (this.options.filterAcceptOnEnter && ['Enter', 'Space'].includes(e.code) && this.searchInputElm?.value) { if (this.options.single) { const visibleLiElms: HTMLInputElement[] = []; this.selectItemElms?.forEach(selectedElm => { if (selectedElm.closest('li')?.style.display !== 'none') { visibleLiElms.push(selectedElm); } }); if (visibleLiElms.length && visibleLiElms[0].hasAttribute('data-name')) { this.setSelects([visibleLiElms[0].value]); } } else { this.selectAllElm?.click(); } this.close(`key.${e.code.toLowerCase() as 'enter' | 'space'}`); this.focus(); return; } this.filter(); }) as EventListener, undefined, 'search-input', ); } if (this.selectAllElm) { this._bindEventService.bind( this.selectAllElm, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => this._checkAll(e.currentTarget?.checked)) as EventListener, undefined, 'select-all-checkbox', ); } if (this.okButtonElm) { this._bindEventService.bind( this.okButtonElm, 'click', ((e: MouseEvent & { target: HTMLElement }) => { toggleOpen(e); e.stopPropagation(); // Causes lost focus otherwise }) as EventListener, undefined, 'ok-button', ); } if (this.selectGroupElms) { this._bindEventService.bind( this.selectGroupElms, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { const selectElm = e.currentTarget; const checked = selectElm.checked; const group = findByParam(this.data, '_key', selectElm.dataset.key); this._checkGroup(group, checked); this.options.onOptgroupClick( removeUndefined({ label: group.label, selected: group.selected, data: group._data, children: group.children.map((child: any) => { if (child) { return removeUndefined({ text: child.text, value: child.value, selected: child.selected, disabled: child.disabled, data: child._data, }); } }), }), ); }) as EventListener, undefined, 'group-checkbox-list', ); } if (this.selectItemElms) { this._bindEventService.bind( this.selectItemElms, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { const selectElm = e.currentTarget; const checked = selectElm.checked; const option = findByParam(this.data, '_key', selectElm.dataset.key); const close = () => { if (this.options.single && this.options.isOpen && !this.options.keepOpen) { this.close('selection'); } }; if (this.options.onBeforeClick(option) === false) { close(); return; } this._check(option, checked); this.options.onClick( removeUndefined({ text: option.text, value: option.value, selected: option.selected, data: option._data, }), ); close(); }) as EventListener, undefined, 'input-checkbox-list', ); } if (this.lastFocusedItemKey && this.dropElm) { // if we previously had an item focused and the VirtualScroll recreates the list, we need to refocus on last item by its input data-key const input = this.dropElm.querySelector<HTMLInputElement>(`li[data-key=${this.lastFocusedItemKey}]`); input?.focus(); } if (this.options.navigationHighlight && this.dropElm) { // when hovering an select option, we will also change the highlight to that option this._bindEventService.bind( this.dropElm, 'mouseover', ((e: MouseEvent & { target: HTMLDivElement | HTMLLIElement }) => { const liElm = (this.getEventTarget(e).closest('.ms-select-all') || this.getEventTarget(e).closest('li')) as HTMLLIElement; if (this.dropElm?.contains(liElm) && this.lastMouseOverPosition !== `${e.clientX}:${e.clientY}`) { const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || []; const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === liElm.dataset.key); if (this._currentHighlightIndex !== newIdx && !liElm.classList.contains('disabled')) { this._currentSelectedElm = liElm; this._currentHighlightIndex = newIdx; this.changeCurrentOptionHighlight(liElm); } } this.lastMouseOverPosition = `${e.clientX}:${e.clientY}`; }) as EventListener, undefined, 'hover-highlight', ); // add keydown event listeners to watch for up/down arrows and focus on previous/next item // we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox // we will also remove any previous bindings that might exist which happen when we use VirtualScroll this._bindEventService.bind( this.dropElm, 'keydown', ((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => { switch (e.key) { case 'ArrowUp': e.preventDefault(); this.moveHighlightUp(); break; case 'ArrowDown': e.preventDefault(); this.moveHighlightDown(); break; case 'Escape': this.handleEscapeKey(); break; case 'Enter': case ' ': { // if we're focused on the OK button then don't execute following block if (document.activeElement !== this.okButtonElm) { const liElm = this.getEventTarget(e).closest('.ms-select-all') || this.getEventTarget(e).closest('li'); if ((e.key === ' ' && this.options.filter) || (this.options.filterAcceptOnEnter && !liElm)) { return; } e.preventDefault(); this._currentSelectedElm?.querySelector('input')?.click(); // on single select, we should focus directly if (this.options.single) { this.choiceElm.focus(); this.lastFocusedItemKey = this.choiceElm?.dataset.key || ''; } } break; } case 'Tab': { // when clicking Tab, we'll focus on OK button when available // or with Shift+Tab we'll either focus first option when coming // from OK button or close drop if we're already in the lsit e.preventDefault(); if (e.shiftKey) { if (document.activeElement === this.okButtonElm) { this.focusSelectAllOrList(); this.highlightCurrentOption(); } else { this.close('key.shift+tab'); this.choiceElm.focus(); } } else { this.changeCurrentOptionHighlight(); this.okButtonElm?.focus(); } break; } } }) as EventListener, undefined, 'arrow-highlight', ); } if (this.ulElm && this.options.infiniteScroll) { this._bindEventService.bind( this.ulElm, 'scroll', this.infiniteScrollHandler.bind(this) as EventListener, undefined, 'option-list-scroll', ); } } protected handleEscapeKey() { if (!this.options.keepOpen) { this.close('key.escape'); this.choiceElm.focus(); } } /** * Checks if user reached the end of the list through mouse scrolling and/or arrow down, * then scroll back to the top whenever that happens. */ protected infiniteScrollHandler(e: (MouseEvent & { target: HTMLElement }) | null, idx?: number, fullCount?: number) { let needHighlightRecalc = false; if (e && this.getEventTarget(e) && this.ulElm && this.scrolledByMouse) { const scrollPos = this.getEventTarget(e).scrollTop + this.getEventTarget(e).clientHeight; if (scrollPos === this.ulElm.scrollHeight) { needHighlightRecalc = true; } } else if (idx !== undefined && idx + 1 === fullCount) { needHighlightRecalc = true; } if (needHighlightRecalc && this.ulElm) { if (this.virtualScroll) { this.initListItems(); } else { this.ulElm.scrollTop = 0; } this._currentHighlightIndex = 0; this.highlightCurrentOption(); } } /** * Open the drop method, user could optionally provide a delay in ms to open the drop. * The default delay is 0ms (which is 1 CPU cycle) when nothing is provided, to avoid a delay we can pass `-1` or `null` * @param {number} [openDelay=0] - provide an optional delay, defaults to 0 */ open(openDelay: number | null = 0): Promise<void> { return new Promise(resolve => { if (openDelay !== null && openDelay >= 0) { // eslint-disable-next-line prefer-const window.clearTimeout(this.openDelayTimer); this.openDelayTimer = window.setTimeout(() => { this.openDrop(); resolve(); }, openDelay); } else { this.openDrop(); resolve(); } }); } protected openDrop() { if (!this.dropElm || this.choiceElm?.classList.contains('disabled')) { return; } this.options.isOpen = true; this.parentElm.classList.add('ms-parent-open'); this.choiceElm?.querySelector('div.ms-icon-caret')?.classList.add('open'); this.dropElm.style.display = 'block'; this.dropElm.ariaExpanded = 'true'; if (this.selectAllElm?.parentElement) { this.selectAllElm.parentElement.style.display = 'inline-flex'; } if (this.noResultsElm) { this.noResultsElm.style.display = 'none'; } if (!this.getDataLength()) { if (this.selectAllElm?.parentElement) { this.selectAllElm.parentElement.style.display = 'none'; } if (this.noResultsElm) { this.noResultsElm.style.display = 'block'; } } if (this.options.container) { const offset = getElementOffset(this.dropElm); let container: HTMLElement; if (this.options.container instanceof Node) { container = this.options.container as HTMLElement; } else if (typeof this.options.container === 'string') { container = this.options.container === 'body' ? document.body : (document.querySelector(this.options.container) as HTMLElement); } container!.appendChild(this.dropElm); this.dropElm.style.top = `${offset?.top ?? 0}px`; this.dropElm.style.left = `${offset?.left ?? 0}px`; this.dropElm.style.minWidth = 'auto'; this.dropElm.style.width = `${getElementSize(this.parentElm, 'outer', 'width')}px`; } const minHeight = this.options.minHeight; let maxHeight = this.options.maxHeight; if (this.options.maxHeightUnit === 'row') { maxHeight = getElementSize(this.dropElm.querySelector('ul>li') as HTMLLIElement, 'outer', 'height') * this.options.maxHeight; } this.ulElm ??= this.dropElm.querySelector('ul'); if (this.ulElm) { if (minHeight) { this.ulElm.style.minHeight = `${minHeight}px`; } this.ulElm.style.maxHeight = `${maxHeight}px`; } this.dropElm.querySelectorAll<HTMLDivElement>('.multiple').forEach(multElm => { multElm.style.width = `${this.options.multipleWidth}px`; }); if (this.getDataLength() && this.options.filter) { if (this.searchInputElm) { this.searchInputElm.value = ''; this.searchInputElm.focus(); } this.filter(true); } else { // highlight SelectAll or 1st select option when opening dropdown this.focusSelectAllOrList(); } if (this._currentHighlightIndex < 0) { // on open drop initial, we'll focus on next available option this.moveHighlightDown(); } else { // if it was already opened earlier, we'll keep same option index focused this.highlightCurrentOption(); } if (this.options.autoAdjustDropWidthByTextSize) { this.adjustDropWidthByText(); } let newPosition = this.options.position; if (this.options.autoAdjustDropHeight) { // if autoAdjustDropPosition is enable, we 1st need to see what position the drop will be located // without necessary toggling it's position just yet, we just want to know the future position for calculation if (this.options.autoAdjustDropPosition) { const { bottom: spaceBottom, top: spaceTop } = calculateAvailableSpace(this.dropElm); const msDropHeight = this.dropElm.getBoundingClientRect().height; newPosition = spaceBottom < msDropHeight && spaceTop > spaceBottom ? 'top' : 'bottom'; } // now that we know which drop position will be used, let's adjust the drop height this.adjustDropHeight(newPosition); } if (this.options.autoAdjustDropPosition) { this.adjustDropPosition(true); } this.options.onOpen(); } protected focusSelectAllOrList() { if (this.selectAllElm) { this.selectAllElm.focus(); } else if (this.ulElm) { this.ulElm.tabIndex = 0; this.ulElm.focus(); } } protected highlightCurrentOption() { const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || []; if (this._currentHighlightIndex <= optionElms.length) { const currentOption = optionElms[this._currentHighlightIndex]; if (currentOption) { this.lastFocusedItemKey = currentOption.dataset.key || ''; this._currentSelectedElm = currentOption; // Scroll the current option into view // use a global flag to differentiate scroll by mouse or by scrollIntoView this.scrolledByMouse = false; currentOption.scrollIntoView({ block: 'nearest' }); this.changeCurrentOptionHighlight(currentOption); window.setTimeout(() => (this.scrolledByMouse = true), 10); } } } /** Change highlighted option, or remove highlight when nothing is provided */ protected changeCurrentOptionHighlight(optionElm?: HTMLLIElement | HTMLDivElement) { optionElm?.classList.add('highlighted'); const currentElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_HIGHLIGHT_LIST_SELECTOR) || []; currentElms.forEach(option => { if (option !== optionElm) { option.classList.remove('highlighted'); } }); } protected moveHighlightDown() { const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || []; const domOptionsCount = optionElms.length; if (this._currentHighlightIndex < domOptionsCount - 1) { this._currentHighlightIndex++; if (optionElms[this._currentHighlightIndex]?.classList.contains('disabled')) { this.moveHighlightDown(); } } else if (this.options.infiniteScroll) { this.infiniteScrollHandler(null, this._currentHighlightIndex, domOptionsCount); } this.highlightCurrentOption(); } protected moveHighlightUp() { const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || []; const idxToCompare = this.options.single ? 0 : 1; if (this.virtualScroll && this._currentHighlightIndex <= idxToCompare && this.updateDataStart! > 0 && this.ulElm) { const currentOptionElm = optionElms[this._currentHighlightIndex + (this.options.single ? 0 : 1)]; // skip SelectAll when using multiple const dataKey = currentOptionElm?.dataset.key; this.lastFocusedItemKey = dataKey as string; // scroll up by 1 option row to trick the v-scroll in thinking it changed v-scroll page and it needs to recalculate its new offset this.ulElm.scrollTop = this.ulElm.scrollTop - currentOptionElm?.getBoundingClientRect().height || 10; // moveUp will be recalled by vScroll callback this.isMoveUpRecalcRequired = true; return; } if (this._currentHighlightIndex > 0) { this._currentHighlightIndex--; if (optionElms[this._currentHighlightIndex]?.classList.contains('disabled')) { this.moveHighlightUp(); } } this.highlightCurrentOption(); } protected recalculateArrowMove(direction: 'up' | 'down') { const optionElms = this.dropElm?.querySelectorAll<HTMLLIElement>(OPTIONS_LIST_SELECTOR) || []; const newIdx = Array.from(optionElms).findIndex(el => el.dataset.key === this.lastFocusedItemKey); this._currentHighlightIndex = newIdx - 1; if (direction === 'down') { this.moveHighlightDown(); } else if (direction === 'up') { this.moveHighlightUp(); this.isMoveUpRecalcRequired = false; } } close(reason?: CloseReason) { this.options.isOpen = false; this.parentElm.classList.remove('ms-parent-open'); this.choiceElm?.querySelector('div.ms-icon-caret')?.classList.remove('open'); if (this.dropElm) { this.dropElm.style.display = 'none'; this.dropElm.ariaExpanded = 'false'; if (this.options.container) { this.parentElm.appendChild(this.dropElm); this.dropElm.style.top = 'auto'; this.dropElm.style.left = 'auto'; } } this.options.onClose(reason); } /** * apply value to an HTML element as text or as HTML with innerHTML when enabled * @param elm * @param value */ protected applyAsTextOrHtmlWhenEnabled(elmOrProp: HTMLElement | any, value: string) { if (!elmOrProp) { elmOrProp = {}; } if (this.isRenderAsHtml) { elmOrProp.innerHTML = (typeof this.options.sanitizer === 'function' ? this.options.sanitizer(value) : value) as unknown as string; } else { elmOrProp.textContent = value; } } protected update(ignoreTrigger = false) { const valueSelects = this.getSelects(); let textSelects = this.getSelects('text'); if (this.options.displayValues) { textSelects = valueSelects; } const spanElm = this.choiceElm?.querySelector<HTMLSpanElement>('span'); const sl = valueSelects.length; let html = null; const getSelectOptionHtml = () => { if (this.options.useSelectOptionLabel || this.options.useSelectOptionLabelToHtml) { const labels = valueSelects.join(this.options.displayDelimiter); return this.options.useSelectOptionLabelToHtml ? stripScripts(labels) : labels; } return textSelects.join(this.options.displayDelimiter); }; if (spanElm) { if (sl === 0) { const placeholder = this.options.placeholder || ''; spanElm.classList.add('ms-placeholder'); this.applyAsTextOrHtmlWhenEnabled(spanElm, placeholder); } else if (sl < this.options.minimumCountSelected) { html = getSelectOptionHtml(); } else if (this.formatAllSelected() && sl === this.dataTotal) { html = this.formatAllSelected(); } else if (this.options.ellipsis && sl > this.options.minimumCountSelected) { html = `${textSelects.slice(0, this.options.minimumCountSelected).join(this.options.displayDelimiter)}...`; } else if (this.formatCountSelected(sl, this.dataTotal) && sl > this.options.minimumCountSelected) { html = this.formatCountSelected(sl, this.data