/* eslint-disable no-return-assign */import { queryOne } from '@ecl/dom-utils';import getSystem from '@ecl/builder/utils/getSystem';import EventManager from '@ecl/event-manager';import iconSvgAllCheck from '@ecl/resources-icons/dist/svg/all/check.svg';import iconSvgAllCornerArrow from '@ecl/resources-icons/dist/svg/all/corner-arrow.svg';const system = getSystem();const iconSize = system === 'eu' ? 's' : 'xs';/** * This API mostly refers to the multiple select, in the default select only two methods are actually used: * handleKeyboardOnSelect() and handleOptgroup(). * * For the multiple select there are multiple labels contained in this component. You can set them in 2 ways: * directly as a string or through data attributes. * Textual values have precedence and if they are not provided, then DOM data attributes are used. * * @param {HTMLElement} element DOM element for component instantiation and scope * @param {Object} options * @param {String} options.defaultText The default placeholder * @param {String} options.searchText The label for search * @param {String} options.selectAllText The label for select all * @param {String} options.selectMultipleSelector The data attribute selector of the select multiple * @param {String} options.defaultTextAttribute The data attribute for the default placeholder text * @param {String} options.searchTextAttribute The data attribute for the default search text * @param {String} options.selectAllTextAttribute The data attribute for the select all text * @param {String} options.noResultsTextAttribute The data attribute for the no results options text * @param {String} options.closeLabelAttribute The data attribute for the close button * @param {String} options.clearAllLabelAttribute The data attribute for the clear all button * @param {String} options.selectMultiplesSelectionCountSelector The selector for the counter of selected options * @param {String} options.closeButtonLabel The label of the close button * @param {String} options.clearAllButtonLabel The label of the clear all button */export class Select { /** * @static * Shorthand for instance creation and initialisation. * * @param {HTMLElement} root DOM element for component instantiation and scope * * @return {Select} An instance of Select. */ static autoInit(root, defaultOptions = {}) { const select = new Select(root, defaultOptions); select.init(); root.ECLSelect = select; return select; } /** * @event Select#onToggle */ /** * @event Select#onSelection */ /** * @event Select#onSelectAll */ /** * @event Select#onReset */ /** * @event Select#onSearch * */ supportedEvents = [ 'onToggle', 'onSelection', 'onSelectAll', 'onReset', 'onSearch', ]; constructor( element, { defaultText = '', searchText = '', selectAllText = '', noResultsText = '', selectMultipleSelector = '[data-ecl-select-multiple]', defaultTextAttribute = 'data-ecl-select-default', searchTextAttribute = 'data-ecl-select-search', selectAllTextAttribute = 'data-ecl-select-all', noResultsTextAttribute = 'data-ecl-select-no-results', closeLabelAttribute = 'data-ecl-select-close', clearAllLabelAttribute = 'data-ecl-select-clear-all', selectMultiplesSelectionCountSelector = 'ecl-select-multiple-selections-counter', closeButtonLabel = '', clearAllButtonLabel = '', } = {}, ) { // Check element if (!element || element.nodeType !== Node.ELEMENT_NODE) { throw new TypeError( 'DOM element should be given to initialize this widget.', ); } this.element = element; this.eventManager = new EventManager(); // Options this.selectMultipleSelector = selectMultipleSelector; this.selectMultiplesSelectionCountSelector = selectMultiplesSelectionCountSelector; this.defaultTextAttribute = defaultTextAttribute; this.searchTextAttribute = searchTextAttribute; this.selectAllTextAttribute = selectAllTextAttribute; this.noResultsTextAttribute = noResultsTextAttribute; this.defaultText = defaultText; this.searchText = searchText; this.selectAllText = selectAllText; this.noResultsText = noResultsText; this.clearAllButtonLabel = clearAllButtonLabel; this.closeButtonLabel = closeButtonLabel; this.closeLabelAttribute = closeLabelAttribute; this.clearAllLabelAttribute = clearAllLabelAttribute; // Private variables this.input = null; this.search = null; this.checkboxes = null; this.select = null; this.selectAll = null; this.selectIcon = null; this.textDefault = null; this.textSearch = null; this.textSelectAll = null; this.textNoResults = null; this.selectMultiple = null; this.inputContainer = null; this.optionsContainer = null; this.visibleOptions = null; this.searchContainer = null; this.countSelections = null; this.form = null; this.formGroup = null; this.label = null; this.helper = null; this.invalid = null; this.selectMultipleId = null; this.multiple = queryOne(this.selectMultipleSelector, this.element.parentNode) || false; this.isOpen = false; // Bind `this` for use in callbacks this.handleToggle = this.handleToggle.bind(this); this.handleClickOption = this.handleClickOption.bind(this); this.handleClickSelectAll = this.handleClickSelectAll.bind(this); this.handleEsc = this.handleEsc.bind(this); this.handleFocusout = this.handleFocusout.bind(this); this.handleSearch = this.handleSearch.bind(this); this.handleClickOutside = this.handleClickOutside.bind(this); this.resetForm = this.resetForm.bind(this); this.handleClickOnClearAll = this.handleClickOnClearAll.bind(this); this.handleKeyboardOnSelect = this.handleKeyboardOnSelect.bind(this); this.handleKeyboardOnSelectAll = this.handleKeyboardOnSelectAll.bind(this); this.handleKeyboardOnSearch = this.handleKeyboardOnSearch.bind(this); this.handleKeyboardOnOptions = this.handleKeyboardOnOptions.bind(this); this.handleKeyboardOnOption = this.handleKeyboardOnOption.bind(this); this.handleKeyboardOnClearAll = this.handleKeyboardOnClearAll.bind(this); this.handleKeyboardOnClose = this.handleKeyboardOnClose.bind(this); this.setCurrentValue = this.setCurrentValue.bind(this); this.update = this.update.bind(this); } /** * Static method to create an svg icon. * * @static * @private * @returns {HTMLElement} */ static #createSvgIcon(icon, classes) { const tempElement = document.createElement('div'); tempElement.innerHTML = icon; // avoiding the use of not-so-stable createElementNs const svg = tempElement.children[0]; svg.removeAttribute('height'); svg.removeAttribute('width'); svg.setAttribute('focusable', false); svg.setAttribute('aria-hidden', true); // The following element is <path> which does not support classList API as others. svg.setAttribute('class', classes); return svg; } /** * Static method to create a checkbox element. * * @static * @param {Object} options * @param {String} options.id * @param {String} options.text * @param {String} [options.extraClass] - additional CSS class * @param {String} [options.disabled] - relevant when re-creating an option * @param {String} [options.selected] - relevant when re-creating an option * @param {String} ctx * @private * @returns {HTMLElement} */ static #createCheckbox(options, ctx) { // Early returns. if (!options || !ctx) return ''; const { id, text, disabled, selected, extraClass } = options; if (!id || !text) return ''; // Elements to work with. const checkbox = document.createElement('div'); const input = document.createElement('input'); const label = document.createElement('label'); const box = document.createElement('span'); const labelText = document.createElement('span'); // Respect optional input parameters. if (extraClass) { checkbox.classList.add(extraClass); } if (selected) { input.setAttribute('checked', true); } if (disabled) { checkbox.classList.add('ecl-checkbox--disabled'); box.classList.add('ecl-checkbox__box--disabled'); input.setAttribute('disabled', disabled); } // Imperative work follows. checkbox.classList.add('ecl-checkbox'); checkbox.setAttribute('data-select-multiple-value', text); input.classList.add('ecl-checkbox__input'); input.setAttribute('type', 'checkbox'); input.setAttribute('id', `${ctx}-${id}`); input.setAttribute('name', `${ctx}-${id}`); checkbox.appendChild(input); label.classList.add('ecl-checkbox__label'); label.setAttribute('for', `${ctx}-${id}`); box.classList.add('ecl-checkbox__box'); box.setAttribute('aria-hidden', true); box.appendChild( Select.#createSvgIcon( iconSvgAllCheck, 'ecl-icon ecl-icon--s ecl-checkbox__icon', ), ); label.appendChild(box); labelText.classList.add('ecl-checkbox__label-text'); labelText.innerHTML = text; label.appendChild(labelText); checkbox.appendChild(label); return checkbox; } /** * Static method to generate the select icon * * @static * @private * @returns {HTMLElement} */ static #createSelectIcon() { const wrapper = document.createElement('div'); wrapper.classList.add('ecl-select__icon'); const button = document.createElement('button'); button.classList.add( 'ecl-button', 'ecl-button--ghost', 'ecl-button--icon-only', ); button.setAttribute('tabindex', '-1'); const labelWrapper = document.createElement('span'); labelWrapper.classList.add('ecl-button__container'); const label = document.createElement('span'); label.classList.add('ecl-button__label'); label.textContent = 'Toggle dropdown'; labelWrapper.appendChild(label); const icon = Select.#createSvgIcon( iconSvgAllCornerArrow, `ecl-icon ecl-icon--${iconSize} ecl-icon--rotate-180`, ); labelWrapper.appendChild(icon); button.appendChild(labelWrapper); wrapper.appendChild(button); return wrapper; } /** * Static method to programmatically check an ECL-specific checkbox when previously default has been prevented. * * @static * @param {Event} e * @private */ static #checkCheckbox(e) { const input = e.target.closest('.ecl-checkbox').querySelector('input'); input.checked = !input.checked; return input.checked; } /** * Static method to generate a random string * * @static * @param {number} length * @private */ static #generateRandomId(length) { return Math.random().toString(36).substr(2, length); } /** * Initialise component. */ init() { if (!ECL) { throw new TypeError('Called init but ECL is not present'); } ECL.components = ECL.components || new Map(); this.select = this.element; if (this.multiple) { const containerClasses = Array.from(this.select.parentElement.classList); this.textDefault = this.defaultText || this.element.getAttribute(this.defaultTextAttribute); this.textSearch = this.searchText || this.element.getAttribute(this.searchTextAttribute); this.textSelectAll = this.selectAllText || this.element.getAttribute(this.selectAllTextAttribute); this.textNoResults = this.noResultsText || this.element.getAttribute(this.noResultsTextAttribute); this.closeButtonLabel = this.closeButtonLabel || this.element.getAttribute(this.closeLabelAttribute); this.clearAllButtonLabel = this.clearAllButtonLabel || this.element.getAttribute(this.clearAllLabelAttribute); // Retrieve the id from the markup or generate one. this.selectMultipleId = this.element.id || `select-multiple-${Select.#generateRandomId(4)}`; this.element.id = this.selectMultipleId; this.formGroup = this.element.closest('.ecl-form-group'); if (this.formGroup) { this.formGroup.setAttribute('role', 'application'); this.label = queryOne('.ecl-form-label', this.formGroup); this.helper = queryOne('.ecl-help-block', this.formGroup); this.invalid = queryOne('.ecl-feedback-message', this.formGroup); } // Disable focus on default select this.select.setAttribute('tabindex', '-1'); this.selectMultiple = document.createElement('div'); this.selectMultiple.classList.add('ecl-select__multiple'); // Close the searchContainer when tabbing out of the selectMultiple this.selectMultiple.addEventListener('focusout', this.handleFocusout); this.inputContainer = document.createElement('div'); this.inputContainer.classList.add(...containerClasses); this.selectMultiple.appendChild(this.inputContainer); this.input = document.createElement('button'); this.input.classList.add('ecl-select', 'ecl-select__multiple-toggle'); this.input.setAttribute('type', 'button'); this.input.setAttribute( 'aria-controls', `${this.selectMultipleId}-dropdown`, ); this.input.setAttribute('id', `${this.selectMultipleId}-toggle`); this.input.setAttribute('aria-expanded', false); if (containerClasses.find((c) => c.includes('disabled'))) { this.input.setAttribute('disabled', true); } // Add accessibility attributes if (this.label) { this.label.setAttribute('for', `${this.selectMultipleId}-toggle`); this.input.setAttribute('aria-labelledby', this.label.id); } let describedby = ''; if (this.helper) { describedby = this.helper.id; } if (this.invalid) { describedby = describedby ? `${describedby} ${this.invalid.id}` : this.invalid.id; } if (describedby) { this.input.setAttribute('aria-describedby', describedby); } this.input.addEventListener('keydown', this.handleKeyboardOnSelect); this.input.addEventListener('click', this.handleToggle); this.selectionCount = document.createElement('div'); this.selectionCount.classList.add( this.selectMultiplesSelectionCountSelector, ); this.selectionCountText = document.createElement('span'); this.selectionCount.appendChild(this.selectionCountText); this.inputContainer.appendChild(this.selectionCount); this.inputContainer.appendChild(this.input); this.inputContainer.appendChild(Select.#createSelectIcon()); this.searchContainer = document.createElement('div'); this.searchContainer.style.display = 'none'; this.searchContainer.classList.add( 'ecl-select__multiple-dropdown', ...containerClasses, ); this.searchContainer.setAttribute( 'id', `${this.selectMultipleId}-dropdown`, ); this.selectMultiple.appendChild(this.searchContainer); if (this.textSearch) { this.search = document.createElement('input'); this.search.classList.add('ecl-text-input'); this.search.setAttribute('type', 'search'); this.search.setAttribute('placeholder', this.textSearch || ''); this.search.addEventListener('keyup', this.handleSearch); this.search.addEventListener('search', this.handleSearch); this.search.addEventListener('keydown', this.handleKeyboardOnSearch); this.searchContainer.appendChild(this.search); } if (this.textSelectAll) { const optionsCount = Array.from(this.select.options).filter( (option) => !option.disabled, ).length; this.selectAll = Select.#createCheckbox( { id: `all-${Select.#generateRandomId(4)}`, text: `${this.textSelectAll} (${optionsCount})`, extraClass: 'ecl-select__multiple-all', }, this.selectMultipleId, ); this.selectAll.addEventListener('click', this.handleClickSelectAll); this.selectAll.addEventListener('keypress', this.handleClickSelectAll); this.selectAll.addEventListener('change', this.handleClickSelectAll); this.searchContainer.appendChild(this.selectAll); } this.optionsContainer = document.createElement('div'); this.optionsContainer.classList.add('ecl-select__multiple-options'); this.optionsContainer.setAttribute('aria-live', 'polite'); this.searchContainer.appendChild(this.optionsContainer); // Toolbar if (this.clearAllButtonLabel || this.closeButtonLabel) { this.dropDownToolbar = document.createElement('div'); this.dropDownToolbar.classList.add('ecl-select-multiple-toolbar'); if (this.closeButtonLabel) { this.closeButton = document.createElement('button'); this.closeButton.textContent = this.closeButtonLabel; this.closeButton.classList.add('ecl-button', 'ecl-button--primary'); this.closeButton.addEventListener('click', this.handleEsc); this.closeButton.addEventListener( 'keydown', this.handleKeyboardOnClose, ); if (this.dropDownToolbar) { this.dropDownToolbar.appendChild(this.closeButton); this.searchContainer.appendChild(this.dropDownToolbar); this.dropDownToolbar.style.display = 'none'; } } if (this.clearAllButtonLabel) { this.clearAllButton = document.createElement('button'); this.clearAllButton.textContent = this.clearAllButtonLabel; this.clearAllButton.classList.add( 'ecl-button', 'ecl-button--secondary', ); this.clearAllButton.addEventListener( 'click', this.handleClickOnClearAll, ); this.clearAllButton.addEventListener( 'keydown', this.handleKeyboardOnClearAll, ); this.dropDownToolbar.appendChild(this.clearAllButton); } } if (this.selectAll) { this.selectAll.addEventListener( 'keydown', this.handleKeyboardOnSelectAll, ); } this.optionsContainer.addEventListener( 'keydown', this.handleKeyboardOnOptions, ); if (this.select.options && this.select.options.length > 0) { this.checkboxes = Array.from(this.select.options).map((option) => { let optgroup = ''; let checkbox = ''; if (option.parentNode.tagName === 'OPTGROUP') { if ( !queryOne( `fieldset[data-ecl-multiple-group="${option.parentNode.getAttribute( 'label', )}"]`, this.optionsContainer, ) ) { optgroup = document.createElement('fieldset'); const title = document.createElement('legend'); title.classList.add('ecl-select__multiple-group__title'); title.innerHTML = option.parentNode.getAttribute('label'); optgroup.appendChild(title); optgroup.setAttribute( 'data-ecl-multiple-group', option.parentNode.getAttribute('label'), ); optgroup.classList.add('ecl-select__multiple-group'); this.optionsContainer.appendChild(optgroup); } else { optgroup = queryOne( `fieldset[data-ecl-multiple-group="${option.parentNode.getAttribute( 'label', )}"]`, this.optionsContainer, ); } } if (option.selected) { this.#updateSelectionsCount(1); if (this.dropDownToolbar) { this.dropDownToolbar.style.display = 'flex'; } } checkbox = Select.#createCheckbox( { // spread operator does not work in storybook context so we map 1:1 id: option.value, text: option.text, disabled: option.disabled, selected: option.selected, }, this.selectMultipleId, ); checkbox.setAttribute('data-visible', true); if (!checkbox.classList.contains('ecl-checkbox--disabled')) { checkbox.addEventListener('click', this.handleClickOption); checkbox.addEventListener('keydown', this.handleKeyboardOnOption); } if (optgroup) { optgroup.appendChild(checkbox); } else { this.optionsContainer.appendChild(checkbox); } return checkbox; }); } else { this.checkboxes = []; } this.visibleOptions = this.checkboxes; this.select.parentNode.parentNode.insertBefore( this.selectMultiple, this.select.parentNode.nextSibling, ); this.select.parentNode.classList.add('ecl-select__container--hidden'); // Respect default selected options. this.#updateCurrentValue(); this.form = this.element.closest('form'); if (this.form) { this.form.addEventListener('reset', this.resetForm); } document.addEventListener('click', this.handleClickOutside); } else { // Simple select this.#handleOptgroup(); this.select.addEventListener('keydown', this.handleKeyboardOnSelect); } // Set ecl initialized attribute this.element.setAttribute('data-ecl-auto-initialized', 'true'); ECL.components.set(this.element, this); } /** * Update instance. * * @param {Integer} i */ update(i) { this.#updateCurrentValue(); this.#updateSelectionsCount(i); } /** * Set the selected value(s) programmatically. * * @param {string | Array<string>} values - A string or an array of values or labels to set as selected. * @param {string} [op='replace'] - The operation mode. Use 'add' to keep the previous selections. * @throws {Error} Throws an error if an invalid operation mode is provided. * * @example * // Replace current selection with new values * setCurrentValue(['value1', 'value2']); * * // Add to current selection without clearing previous selections * setCurrentValue(['value3', 'value4'], 'add'); * */ setCurrentValue(values, op = 'replace') { if (op !== 'replace' && op !== 'add') { throw new Error('Invalid operation mode. Use "replace" or "add".'); } const valuesArray = typeof values === 'string' ? [values] : values; Array.from(this.select.options).forEach((option) => { if (op === 'replace') { option.selected = false; } if ( valuesArray.includes(option.value) || valuesArray.includes(option.label) ) { option.selected = true; } }); this.update(); } /** * Event callback to show/hide the dropdown * * @param {Event} e * @fires Select#onToggle * @type {function} */ handleToggle(e) { if (e) { e.preventDefault(); } this.input.classList.toggle('ecl-select--active'); if (this.searchContainer.style.display === 'none') { this.searchContainer.style.display = 'block'; this.input.setAttribute('aria-expanded', true); this.isOpen = true; } else { this.searchContainer.style.display = 'none'; this.input.setAttribute('aria-expanded', false); this.isOpen = false; } if (e) { const eventData = { opened: this.isOpen, e }; this.trigger('onToggle', eventData); } } /** * Register a callback function for a specific event. * * @param {string} eventName - The name of the event to listen for. * @param {Function} callback - The callback function to be invoked when the event occurs. * @returns {void} * @memberof Select * @instance * * @example * // Registering a callback for the 'onToggle' event * select.on('onToggle', (event) => { * console.log('Toggle event occurred!', event); * }); */ on(eventName, callback) { this.eventManager.on(eventName, callback); } /** * Trigger a component event. * * @param {string} eventName - The name of the event to trigger. * @param {any} eventData - Data associated with the event. * @memberof Select * @instance * */ trigger(eventName, eventData) { this.eventManager.trigger(eventName, eventData); } /** * Destroy the component instance. */ destroy() { this.input.removeEventListener('keydown', this.handleKeyboardOnSelect); if (this.multiple) { document.removeEventListener('click', this.handleClickOutside); this.selectMultiple.removeEventListener('focusout', this.handleFocusout); this.input.removeEventListener('click', this.handleToggle); if (this.search) { this.search.removeEventListener('keyup', this.handleSearch); this.search.removeEventListener('keydown', this.handleKeyboardOnSearch); } if (this.selectAll) { this.selectAll.removeEventListener('click', this.handleClickSelectAll); this.selectAll.removeEventListener( 'keypress', this.handleClickSelectAll, ); this.selectAll.removeEventListener( 'keydown', this.handleKeyboardOnSelectAll, ); } this.optionsContainer.removeEventListener( 'keydown', this.handleKeyboardOnOptions, ); this.checkboxes.forEach((checkbox) => { checkbox.removeEventListener('click', this.handleClickSelectAll); checkbox.removeEventListener('click', this.handleClickOption); checkbox.removeEventListener('keydown', this.handleKeyboardOnOption); }); if (this.closeButton) { this.closeButton.removeEventListener('click', this.handleEsc); this.closeButton.removeEventListener( 'keydown', this.handleKeyboardOnClose, ); } if (this.clearAllButton) { this.clearAllButton.removeEventListener( 'click', this.handleClickOnClearAll, ); this.clearAllButton.removeEventListener( 'keydown', this.handleKeyboardOnClearAll, ); } if (this.selectMultiple) { this.selectMultiple.remove(); } this.select.parentNode.classList.remove('ecl-select__container--hidden'); } if (this.element) { this.element.removeAttribute('data-ecl-auto-initialized'); ECL.components.delete(this.element); } } /** * Private method to handle the update of the selected options counter. * * @param {Integer} i * @private */ #updateSelectionsCount(i) { let selectedOptionsCount = 0; if (i > 0) { this.selectionCount.querySelector('span').innerHTML += i; } else { selectedOptionsCount = Array.from(this.select.options).filter( (option) => option.selected, ).length; } if (selectedOptionsCount > 0) { this.selectionCount.querySelector('span').innerHTML = selectedOptionsCount; this.selectionCount.classList.add( 'ecl-select-multiple-selections-counter--visible', ); if (this.dropDownToolbar) { this.dropDownToolbar.style.display = 'flex'; } } else { this.selectionCount.classList.remove( 'ecl-select-multiple-selections-counter--visible', ); if (this.dropDownToolbar) { this.dropDownToolbar.style.display = 'none'; } } if (selectedOptionsCount >= 100) { this.selectionCount.classList.add( 'ecl-select-multiple-selections-counter--xxl', ); } } /** * Private method to handle optgroup in single select. * * @private */ #handleOptgroup() { Array.from(this.select.options).forEach((option) => { if (option.parentNode.tagName === 'OPTGROUP') { const groupLabel = option.parentNode.getAttribute('label'); const optionLabel = option.getAttribute('label') || option.textContent; if (groupLabel && optionLabel) { option.setAttribute('aria-label', `${optionLabel} - ${groupLabel}`); } } }); } /** * Private method to update the select value. * * @fires Select#onSelection * @private */ #updateCurrentValue() { const optionSelected = Array.from(this.select.options) .filter((option) => option.selected) // do not rely on getAttribute as it does not work in all cases .map((option) => option.text) .join(', '); this.input.innerHTML = optionSelected || this.textDefault || ''; if (optionSelected !== '' && this.label) { this.label.setAttribute( 'aria-label', `${this.label.innerText} ${optionSelected}`, ); } else if (optionSelected === '' && this.label) { this.label.removeAttribute('aria-label'); } this.trigger('onSelection', { selected: optionSelected }); // Dispatch a change event once the value of the select has changed. this.select.dispatchEvent(new window.Event('change', { bubbles: true })); } /** * Private method to handle the focus switch. * * @param {upOrDown} * @param {loop} * @private */ #moveFocus(upOrDown) { const activeEl = document.activeElement; const hasGroups = activeEl.parentElement.parentElement.classList.contains( 'ecl-select__multiple-group', ); const options = !hasGroups ? Array.from( activeEl.parentElement.parentElement.querySelectorAll( '.ecl-checkbox__input', ), ) : Array.from( activeEl.parentElement.parentElement.parentElement.querySelectorAll( '.ecl-checkbox__input', ), ); const activeIndex = options.indexOf(activeEl); if (upOrDown === 'down') { const nextSiblings = options .splice(activeIndex + 1, options.length) .filter( (el) => !el.disabled && el.parentElement.style.display !== 'none', ); if (nextSiblings.length > 0) { nextSiblings[0].focus(); } else { // eslint-disable-next-line no-lonely-if if ( this.dropDownToolbar && this.dropDownToolbar.style.display === 'flex' ) { this.dropDownToolbar.firstChild.focus(); } } } else { const previousSiblings = options .splice(0, activeIndex) .filter( (el) => !el.disabled && el.parentElement.style.display !== 'none', ); if (previousSiblings.length > 0) { previousSiblings[previousSiblings.length - 1].focus(); } else { this.optionsContainer.scrollTop = 0; if (this.selectAll && !this.selectAll.querySelector('input').disabled) { this.selectAll.querySelector('input').focus(); } else if (this.search) { this.search.focus(); } else { this.input.focus(); this.handleToggle(); } } } } /** * Event callback to handle the click on a checkbox. * * @param {Event} e * @type {function} */ handleClickOption(e) { e.preventDefault(); Select.#checkCheckbox(e); // Toggle values const checkbox = e.target.closest('.ecl-checkbox'); Array.from(this.select.options).forEach((option) => { if (option.text === checkbox.getAttribute('data-select-multiple-value')) { if (option.getAttribute('selected') || option.selected) { option.selected = false; if (this.selectAll) { this.selectAll.querySelector('input').checked = false; } } else { option.selected = true; } } }); this.update(); } /** * Event callback to handle the click on the select all checkbox. * * @param {Event} e * @fires Select#onSelectAll * @type {function} */ handleClickSelectAll(e) { e.preventDefault(); // Early returns. if (!this.selectAll || this.selectAll.querySelector('input').disabled) { return; } const checked = Select.#checkCheckbox(e); const options = Array.from(this.select.options).filter((o) => !o.disabled); const checkboxes = Array.from( this.searchContainer.querySelectorAll('[data-visible="true"]'), ).filter((checkbox) => !checkbox.querySelector('input').disabled); checkboxes.forEach((checkbox) => { checkbox.querySelector('input').checked = checked; const option = options.find( (o) => o.text === checkbox.getAttribute('data-select-multiple-value'), ); if (option) { if (checked) { option.selected = true; } else { option.selected = false; } } }); this.update(); this.trigger('onSelectAll', { selected: options }); } /** * Event callback to handle moving the focus out of the select. * * @param {Event} e * @type {function} */ handleFocusout(e) { if ( e.relatedTarget && this.selectMultiple && !this.selectMultiple.contains(e.relatedTarget) && this.searchContainer.style.display === 'block' ) { this.searchContainer.style.display = 'none'; this.input.classList.remove('ecl-select--active'); this.input.setAttribute('aria-expanded', false); } else if ( e.relatedTarget && !this.selectMultiple && !this.select.parentNode.contains(e.relatedTarget) ) { this.select.blur(); } } /** * Event callback to handle the user typing in the search field. * * @param {Event} e * @fires Select#onSearch * @type {function} */ handleSearch(e) { const dropDownHeight = this.optionsContainer.offsetHeight; this.visibleOptions = []; const keyword = e.target.value.toLowerCase(); let eventDetails = {}; if (dropDownHeight > 0) { this.optionsContainer.style.height = `${dropDownHeight}px`; } this.checkboxes.forEach((checkbox) => { if ( !checkbox .getAttribute('data-select-multiple-value') .toLocaleLowerCase() .includes(keyword) ) { checkbox.removeAttribute('data-visible'); checkbox.style.display = 'none'; } else { checkbox.setAttribute('data-visible', true); checkbox.style.display = 'flex'; // Highlight keyword in checkbox label. const checkboxLabelText = checkbox.querySelector( '.ecl-checkbox__label-text', ); checkboxLabelText.textContent = checkboxLabelText.textContent.replace( '.cls-1{fill:none}', '', ); if (keyword) { checkboxLabelText.innerHTML = checkboxLabelText.textContent.replace( new RegExp(`${keyword}(?!([^<]+)?<)`, 'gi'), '<b>$&</b>', ); } this.visibleOptions.push(checkbox); } }); // Select all checkbox follows along. const checked = this.visibleOptions.filter( (c) => c.querySelector('input').checked, ); if ( this.selectAll && (this.visibleOptions.length === 0 || this.visibleOptions.length !== checked.length) ) { this.selectAll.querySelector('input').checked = false; } else if (this.selectAll) { this.selectAll.querySelector('input').checked = true; } // Display no-results message. const noResultsElement = this.searchContainer.querySelector( '.ecl-select__multiple-no-results', ); const groups = this.optionsContainer.getElementsByClassName( 'ecl-select__multiple-group', ); // eslint-disable-next-line no-restricted-syntax for (const group of groups) { group.style.display = 'none'; // eslint-disable-next-line no-restricted-syntax const groupedCheckboxes = [...group.children].filter((node) => node.classList.contains('ecl-checkbox'), ); groupedCheckboxes.forEach((single) => { if (single.hasAttribute('data-visible')) { single.closest('.ecl-select__multiple-group').style.display = 'block'; } }); } if (this.visibleOptions.length === 0 && !noResultsElement) { // Create no-results element. const noResultsContainer = document.createElement('div'); const noResultsLabel = document.createElement('span'); noResultsContainer.classList.add('ecl-select__multiple-no-results'); noResultsLabel.innerHTML = this.textNoResults; noResultsContainer.appendChild(noResultsLabel); this.optionsContainer.appendChild(noResultsContainer); } else if (this.visibleOptions.length > 0 && noResultsElement !== null) { noResultsElement.parentNode.removeChild(noResultsElement); } // reset if (keyword.length === 0) { this.checkboxes.forEach((checkbox) => { checkbox.setAttribute('data-visible', true); checkbox.style.display = 'flex'; }); // Enable select all checkbox. if (this.selectAll) { this.selectAll.classList.remove('ecl-checkbox--disabled'); this.selectAll.querySelector('input').disabled = false; } } else if (keyword.length !== 0 && this.selectAll) { // Disable select all checkbox. this.selectAll.classList.add('ecl-checkbox--disabled'); this.selectAll.querySelector('input').disabled = true; } if (this.visibleOptions.length > 0) { const visibleLabels = this.visibleOptions.map((option) => { let label = null; const labelEl = queryOne('.ecl-checkbox__label-text', option); if (labelEl) { label = labelEl.innerHTML.replace(/<\/?b>/g, ''); } return label || ''; }); eventDetails = { results: visibleLabels, text: e.target.value.toLowerCase(), }; } else { eventDetails = { results: 'none', text: e.target.value.toLowerCase() }; } this.trigger('onSearch', eventDetails); } /** * Event callback to handle the click outside the select. * * @param {Event} e * @type {function} */ handleClickOutside(e) { if ( e.target && this.selectMultiple && !this.selectMultiple.contains(e.target) && this.searchContainer.style.display === 'block' ) { this.searchContainer.style.display = 'none'; this.input.classList.remove('ecl-select--active'); this.input.setAttribute('aria-expanded', false); } } /** * Event callback to handle keyboard events on the select. * * @param {Event} e * @type {function} */ handleKeyboardOnSelect(e) { switch (e.key) { case 'Escape': e.preventDefault(); this.handleEsc(e); break; case ' ': case 'Enter': if (this.multiple) { e.preventDefault(); this.handleToggle(e); if (this.search) { this.search.focus(); } else if (this.selectAll) { this.selectAll.firstChild.focus(); } else { this.checkboxes[0].firstChild.focus(); } } break; case 'ArrowDown': if (this.multiple) { e.preventDefault(); if (!this.isOpen) { this.handleToggle(e); } if (this.search) { this.search.focus(); } else if (this.selectAll) { this.selectAll.firstChild.focus(); } else { this.checkboxes[0].firstChild.focus(); } } break; default: } } /** * Event callback to handle keyboard events on the select all checkbox. * * @param {Event} e * @type {function} */ handleKeyboardOnSelectAll(e) { switch (e.key) { case 'Escape': e.preventDefault(); this.handleEsc(e); break; case 'ArrowDown': e.preventDefault(); if (this.visibleOptions.length > 0) { this.visibleOptions[0].querySelector('input').focus(); } else { this.input.focus(); } break; case 'ArrowUp': e.preventDefault(); if (this.search) { this.search.focus(); } else { this.input.focus(); this.handleToggle(e); } break; case 'Tab': e.preventDefault(); if (e.shiftKey) { if (this.search) { this.search.focus(); } } else if (this.visibleOptions.length > 0) { this.visibleOptions[0].querySelector('input').focus(); } else { this.input.focus(); } break; default: } } /** * Event callback to handle keyboard events on the dropdown. * * @param {Event} e * @type {function} */ handleKeyboardOnOptions(e) { switch (e.key) { case 'Escape': e.preventDefault(); this.handleEsc(e); break; case 'ArrowDown': e.preventDefault(); this.#moveFocus('down'); break; case 'ArrowUp': e.preventDefault(); this.#moveFocus('up'); break; case 'Tab': e.preventDefault(); if (e.shiftKey) { this.#moveFocus('up'); } else { this.#moveFocus('down'); } break; default: } } /** * Event callback to handle keyboard events * * @param {Event} e * @type {function} */ handleKeyboardOnSearch(e) { switch (e.key) { case 'Escape': e.preventDefault(); this.handleEsc(e); break; case 'ArrowDown': e.preventDefault(); if (!this.selectAll || this.selectAll.querySelector('input').disabled) { if (this.visibleOptions.length > 0) { this.visibleOptions[0].querySelector('input').focus(); } else { this.input.focus(); } } else { this.selectAll.querySelector('input').focus(); } break; case 'ArrowUp': e.preventDefault(); this.input.focus(); this.handleToggle(e); break; default: } } /** * Event callback to handle the click on an option. * * @param {Event} e * @type {function} */ handleKeyboardOnOption(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.handleClickOption(e); } } /** * Event callback to handle keyboard events on the clear all button. * * @param {Event} e * @fires Select#onReset * @type {function} */ handleKeyboardOnClearAll(e) { e.preventDefault(); switch (e.key) { case 'Enter': case ' ': this.handleClickOnClearAll(e); this.trigger('onReset', e); this.input.focus(); break; case 'ArrowDown': this.input.focus(); break; case 'ArrowUp': if (this.closeButton) { this.closeButton.focus(); } else if (this.visibleOptions.length > 0) { this.visibleOptions[this.visibleOptions.length - 1] .querySelector('input') .focus(); } else if (this.search) { this.search.focus(); } else { this.input.focus(); this.handleToggle(e); } break; case 'Tab': if (e.shiftKey) { if (this.closeButton) { this.closeButton.focus(); } else if (this.visibleOptions.length > 0) { this.visibleOptions[this.visibleOptions.length - 1] .querySelector('input') .focus(); } else if (this.search) { this.search.focus(); } else { this.input.focus(); this.handleToggle(e); } } else { this.input.focus(); this.handleToggle(e); } break; default: } } /** * Event callback for handling keyboard events in the close button. * * @param {Event} e * @type {function} */ handleKeyboardOnClose(e) { e.preventDefault(); switch (e.key) { case 'Enter': case ' ': this.handleEsc(e); this.input.focus(); break; case 'ArrowUp': if (this.visibleOptions.length > 0) { this.visibleOptions[this.visibleOptions.length - 1] .querySelector('input') .focus(); } else { this.input.focus(); this.handleToggle(e); } break; case 'ArrowDown': if (this.clearAllButton) { this.clearAllButton.focus(); } else { this.input.focus(); this.handleToggle(e); } break; case 'Tab': if (!e.shiftKey) { if (this.clearAllButton) { this.clearAllButton.focus(); } else { this.input.focus(); this.handleToggle(e); } } else { // eslint-disable-next-line no-lonely-if if (this.visibleOptions.length > 0) { this.visibleOptions[this.visibleOptions.length - 1] .querySelector('input') .focus(); } else { this.input.focus(); this.handleToggle(e); } } break; default: } } /** * Event callback to handle different events which will close the dropdown. * * @param {Event} e * @type {function} */ handleEsc(e) { if (this.multiple) { e.preventDefault(); this.searchContainer.style.display = 'none'; this.input.setAttribute('aria-expanded', false); this.input.blur(); this.input.classList.remove('ecl-select--active'); } else { this.select.classList.remove('ecl-select--active'); } } /** * Event callback to handle the click on the clear all button. * * @param {Event} e * @fires Select#onReset * @type {function} */ handleClickOnClearAll(e) { e.preventDefault(); Array.from(this.select.options).forEach((option) => { const checkbox = this.selectMultiple.querySelector( `[data-select-multiple-value="${option.text}"]`, ); const input = checkbox.querySelector('.ecl-checkbox__input'); input.checked = false; option.selected = false; }); if (this.selectAll) { this.selectAll.querySelector('.ecl-checkbox__input').checked = false; } this.update(0); this.trigger('onReset', e); } /** * Event callback to reset the multiple select on form reset. * * @type {function} */ resetForm() { if (this.multiple) { // A slight timeout is necessary to execute the function just after the original reset of the form. setTimeout(() => { Array.from(this.select.options).forEach((option) => { const checkbox = this.selectMultiple.querySelector( `[data-select-multiple-value="${option.text}"]`, ); const input = checkbox.querySelector('.ecl-checkbox__input'); if (input.checked) { option.selected = true; } else { option.selected = false; } }); this.update(0); }, 10); } }}export default Select;