// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Multiselect component for Insight UI.
*
* A select field which allows multiple selected values with an integrated
* search field to filter options. Selected values are shown as removable badges.
* Supports configurable maximum selections and select/deselect all buttons.
*
* @example
* // HTML structure
*
*/
export class Multiselect {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/**
* Creates a new Multiselect instance.
*
* @param {HTMLElement} element - The multiselect container element with data-insight-multiselect attribute
*/
constructor(element) {
// If an instance for this element already exists, return it
if (Multiselect.instances.has(element)) {
return Multiselect.instances.get(element);
}
this.element = element;
this.name = element.dataset.name || "multiselect";
this.max = parseInt(element.dataset.max) || Infinity;
this.disabled = element.dataset.disabled === "true";
if (!element.dataset.selected || !element.dataset.selected.trim()) this.selectedValues = [];
else this.selectedValues = JSON.parse(element.dataset.selected.replace(/'/g, '"'));
this.focusedIndex = -1;
this.combobox = this.element.querySelector('[role="combobox"]');
this.selected = this.element.querySelector('.selected');
this.tags = this.element.querySelector('.tags');
this.search = this.element.querySelector('.search');
this.options = this.element.querySelector('.options');
this.optionItems = Array.from(this.element.querySelectorAll('.option'));
this.info = this.element.querySelector('.info');
this.ariaStatus = this.element.querySelector(`#${this.name}-aria-status`);
this.selectAllBtn = this.element.querySelector('.select-all');
this.deselectAllBtn = this.element.querySelector('.deselect-all');
// Hide already selected options
this.optionItems.forEach(opt => {
const value = opt.dataset.value ?? opt.textContent.trim();
if (this.selectedValues.includes(value)) {
opt.setAttribute('aria-selected', 'true');
opt.hidden = true;
}
});
// Only bind interactive events if not disabled
if (!this.disabled) {
this.bindEvents();
}
this.renderSelected();
this.updateInfo();
this.updateAriaStatus();
this.element.__insightInstance = this;
Multiselect.instances.set(element, this);
debugLog("New multiselect created: ", this.element, this.name);
}
/**
* Binds all event listeners to the search field, buttons, and options.
*/
bindEvents() {
// Bind handlers for proper cleanup
this.boundSearchInput = () => { this.filterOptions(this.search.value); this.toggleDropdown(true); };
this.boundSearchFocus = () => { this.toggleDropdown(true); };
this.boundSearchBlur = () => { this.search.value = ''; this.filterOptions(''); };
this.boundSelectedClick = () => { this.toggleDropdown(true); this.search.focus(); };
this.boundSearchKeydown = this.handleSearchKeydown.bind(this);
this.boundDocumentClick = this.handleDocumentClick.bind(this);
this.boundSelectAll = () => { this.selectAll(); };
this.boundDeselectAll = () => { this.deselectAll(); };
this.search.addEventListener('input', this.boundSearchInput);
this.search.addEventListener('focus', this.boundSearchFocus);
this.search.addEventListener('blur', this.boundSearchBlur);
this.selected.addEventListener('click', this.boundSelectedClick);
this.search.addEventListener('keydown', this.boundSearchKeydown);
// Store bound option click handlers for cleanup
this.boundOptionClicks = [];
this.optionItems.forEach(opt => {
const handler = () => this.toggleSelect(opt);
this.boundOptionClicks.push({ element: opt, handler });
opt.addEventListener('click', handler);
});
document.addEventListener('click', this.boundDocumentClick);
if (this.selectAllBtn) this.selectAllBtn.addEventListener('click', this.boundSelectAll);
if (this.deselectAllBtn) this.deselectAllBtn.addEventListener('click', this.boundDeselectAll);
}
/**
* Handles keyboard navigation in the search field.
* Supports Arrow keys, Enter, Backspace, Escape, and Tab.
*
* @param {KeyboardEvent} e - The keydown event
*/
handleSearchKeydown(e) {
const visible = this.optionItems.filter(o => o.style.display !== 'none');
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!visible.length) return;
this.focusedIndex = (this.focusedIndex + 1) % visible.length;
this.focusOption(visible[this.focusedIndex]);
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!visible.length) return;
this.focusedIndex = (this.focusedIndex - 1 + visible.length) % visible.length;
this.focusOption(visible[this.focusedIndex]);
}
else if (e.key === 'Enter') {
e.preventDefault();
if (!visible.length) return;
if (this.search.value.trim() === '' && this.focusedIndex === -1) return;
const opt = this.focusedIndex >= 0 ? visible[this.focusedIndex] : visible[0];
this.toggleSelect(opt);
}
else if (e.key === 'Backspace' && this.search.value === '') {
if (this.selectedValues.length > 0)
this.deselectValue(this.selectedValues[this.selectedValues.length - 1]);
}
else if (e.key === 'Escape' || e.key === 'Tab') this.toggleDropdown(false);
}
/**
* Handles clicks to close the dropdown when clicking outside the interactive area.
*
* Closes the dropdown when clicking outside the combobox area, except for
* the select/deselect all buttons which have their own handlers.
*
* @param {MouseEvent} e - The click event
*/
handleDocumentClick(e) {
// Always close if click is outside the entire component
if (!this.element.contains(e.target)) {
this.toggleDropdown(false);
return;
}
// Keep open if clicking within the input area or options dropdown.
if (this.selected.contains(e.target) || this.options.contains(e.target)) return;
// Keep open if clicking select/deselect all buttons
if (this.selectAllBtn?.contains(e.target)) return;
if (this.deselectAllBtn?.contains(e.target)) return;
// Close for clicks on other areas (label, info text, empty space)
this.toggleDropdown(false);
}
/**
* Show or hide the list of options.
*
* Toggle the 'hidden' class and set 'aria-expanded' to 'true' or 'false'.
* Set 'focusedIndex' to -1 if the list is shown.
*
* @param {boolean} show 'true' to show the list of options, otherwise 'false'.
*/
toggleDropdown(show) {
if (show) {
this.options.classList.remove('hidden');
this.combobox.setAttribute('aria-expanded', 'true');
} else {
this.options.classList.add('hidden');
this.combobox.setAttribute('aria-expanded', 'false');
this.focusedIndex = -1;
}
}
/**
* Highlight the focused option in the list.
*
* @param {HTMLElement} opt The option element.
* @returns null If the option ist empty.
*/
focusOption(opt) {
this.optionItems.forEach(o => o.classList.remove('bg-blue-50'));
if (!opt) return;
opt.classList.add('bg-blue-50');
this.combobox.setAttribute('aria-activedescendant', opt.id);
}
/**
* Select or deselect the given option.
*
* @param {HTMLElement} opt The option element.
* @returns null If the maximum amount of selected values is reached.
*/
toggleSelect(opt) {
const value = opt.dataset.value ?? opt.textContent.trim();
if (this.selectedValues.includes(value))
this.deselectValue(value);
else {
if (this.selectedValues.length >= this.max) {
this.selected.classList.add('animate-shake', 'border-red-500');
setTimeout(() => this.selected.classList.remove('animate-shake', 'border-red-500'), 300);
return;
}
this.selectedValues.push(value);
opt.setAttribute('aria-selected', 'true');
opt.hidden = true;
this.renderSelected();
this.dispatchEvent();
}
this.search.value = '';
this.filterOptions('');
this.search.focus();
}
/**
* Deselect the option with the given value.
*
* Remove the corresponding option from the list of selected options,
* set 'aria-selected' to 'false' and 'display' to 'block' for the option.
* Update DOM and open search field.
*
* @param {String} value Value of the option (from data-value or textContent).
*/
deselectValue(value) {
this.selectedValues = this.selectedValues.filter(v => v !== value);
this.optionItems.forEach(opt => {
const optValue = opt.dataset.value ?? opt.textContent.trim();
if (optValue === value) {
opt.setAttribute('aria-selected', 'false');
opt.hidden = false;
}
});
this.renderSelected();
this.dispatchEvent();
this.search.focus();
}
/**
* Create badges for the selected options.
*
* Create a badge with the display text of the option and a remove button for each selected option.
* The badges are placed at the begin of the search field.
* Update information and 'aria-status' for each option.
*/
renderSelected() {
this.tags.innerHTML = '';
this.element.querySelectorAll('input[type=hidden]').forEach(i => i.remove());
this.selectedValues.forEach(value => {
// Find the option element to get its display text
const opt = this.optionItems.find(o => (o.dataset.value ?? o.textContent.trim()) === value);
const displayText = opt ? opt.textContent.trim() : value;
const tag = document.createElement('span');
tag.className = 'inline-tag me-1';
tag.textContent = displayText;
// Only add remove button if not disabled
if (!this.disabled) {
const remove = document.createElement('button');
remove.innerHTML = '×';
remove.className = 'text-blue-500 hover:text-blue-700 ml-1';
remove.addEventListener('click', e => { e.stopPropagation(); this.deselectValue(value); });
tag.appendChild(remove);
}
this.tags.appendChild(tag);
const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = this.name; hidden.value = value;
this.element.appendChild(hidden);
});
this.updateInfo();
this.updateAriaStatus();
}
/**
* Filter option in relation to the given term.
*
* Hide all option whose display text does not contain the given term (not case sensitive).
* Set 'focusedIndex' to -1.
*
* @param {String} term Search input.
*/
filterOptions(term) {
const lower = term.toLowerCase();
this.optionItems.forEach(opt => {
const displayText = opt.textContent.toLowerCase();
const optValue = opt.dataset.value ?? opt.textContent.trim();
const hiddenBySelection = this.selectedValues.includes(optValue);
opt.hidden = (!hiddenBySelection && displayText.includes(lower)) ? false : true;
});
this.focusedIndex = -1;
}
/**
* Update the info text.
*/
updateInfo() {
if (this.max === Infinity) {
this.info.textContent = '';
return;
}
const text = gettext('%(count)s/%(max)s selected');
this.info.textContent = interpolate(text, {
count: this.selectedValues.length,
max: this.max
}, true);
}
/**
* Update the 'aria-status'.
*/
updateAriaStatus() {
const count = this.selectedValues.length;
const text = ngettext(
'%(count)s option selected',
'%(count)s options selected',
count
);
this.ariaStatus.textContent = interpolate(text, {
count: count
}, true);
}
/**
* Select options up to the maximum allowed limit.
*
* Adds options to the selected values until the max limit is reached.
* If max is not set (Infinity), selects all options.
*/
selectAll() {
this.optionItems.forEach(opt => {
const value = opt.dataset.value ?? opt.textContent.trim();
if (!this.selectedValues.includes(value)) {
// Respect max limit
if (this.selectedValues.length >= this.max) {
return;
}
this.selectedValues.push(value);
opt.setAttribute('aria-selected', 'true');
opt.hidden = true;
}
});
this.renderSelected();
this.dispatchEvent();
}
/**
* Deselect every option, so that nothing is selected.
*
* Clear list of selected values, set 'aria-selected' to 'false' and
* 'hidden' to 'false' for every option. Update DOM and open search field.
*/
deselectAll() {
this.selectedValues = [];
this.optionItems.forEach(opt => {
opt.setAttribute('aria-selected', 'false');
opt.hidden = false;
});
this.renderSelected();
this.dispatchEvent();
this.search.focus();
}
/**
* Dispatch a "change" event with a list of the selected values.
*
* Because this is a custom input-element the event has to be dispatched manually.
*/
dispatchEvent() {
this.element.dispatchEvent(new CustomEvent("change", {
detail: { value: this.selectedValues },
bubbles: true
}));
}
/**
* Destroys the multiselect instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy multiselect: ", this.element, this.name);
// Only remove event listeners if they were bound (not disabled)
if (!this.disabled) {
// Remove search and selected listeners
this.search.removeEventListener('input', this.boundSearchInput);
this.search.removeEventListener('focus', this.boundSearchFocus);
this.search.removeEventListener('blur', this.boundSearchBlur);
this.selected.removeEventListener('click', this.boundSelectedClick);
this.search.removeEventListener('keydown', this.boundSearchKeydown);
// Remove option click handlers
this.boundOptionClicks.forEach(({ element, handler }) => {
element.removeEventListener('click', handler);
});
this.boundOptionClicks = [];
// Remove document click handler
document.removeEventListener('click', this.boundDocumentClick);
// Remove button handlers
if (this.selectAllBtn) this.selectAllBtn.removeEventListener('click', this.boundSelectAll);
if (this.deselectAllBtn) this.deselectAllBtn.removeEventListener('click', this.boundDeselectAll);
}
Multiselect.instances.delete(this.element);
delete this.element.__insightInstance;
this.element = null;
}
/**
* Initializes all multiselect instances on the page.
*
* @static
*/
static initAll() {
document.querySelectorAll('[data-insight-multiselect]').forEach(el => new Multiselect(el));
}
}