This repository has been archived on 2026-02-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
swift-duct-calc/Public/js/daisy-multiselect.js

3091 lines
109 KiB
JavaScript

// from: https://github.com/Mashiane/DaisyUI-MultiSelect-with-CheckBoxes
// Daisy Multi-Select Component
// Auto-inject styles
if (!document.getElementById('daisy-multiselect-styles')) {
const style = document.createElement('style');
style.id = 'daisy-multiselect-styles';
style.textContent = `
/* CRITICAL: Override DaisyUI menu-active globally for entire component */
daisy-multiselect .menu-active,
daisy-multiselect li.menu-active,
daisy-multiselect .menu li:active,
daisy-multiselect .menu li:focus,
daisy-multiselect .menu-vertical li:active,
daisy-multiselect .menu-vertical li:focus {
background-color: transparent !important;
background: transparent !important;
color: inherit !important;
}
/* Additional custom styles for the multi-select */
.multiselect-dropdown {
max-height: 300px;
overflow-y: auto;
}
/* Prevent text flicker during updates */
.multiselect-display {
user-select: none;
-webkit-user-select: none;
backface-visibility: hidden;
-webkit-font-smoothing: subpixel-antialiased;
transform: translateZ(0); /* Force GPU acceleration */
contain: layout style; /* Isolate from external layout changes */
}
/* Disabled state border */
.multiselect-trigger.input-disabled {
border-color: rgba(0, 0, 0, 0.1);
border-width: 1px;
border-style: solid;
}
.multiselect-option {
cursor: pointer;
transition: background-color 0.15s ease-out;
}
/* Prevent text content from transitioning/fading during selection */
.multiselect-option * {
transition: none !important;
}
/* Re-enable transition only for checkbox */
.multiselect-option input[type="checkbox"] {
transition: none !important;
}
/* Completely override ALL DaisyUI menu active/focus states */
.multiselect-option:focus,
.multiselect-option:focus-visible,
.multiselect-option:focus-within,
.multiselect-option:active,
.multiselect-option.active,
.multiselect-option.menu-active,
.multiselect-option[aria-pressed="true"],
li.multiselect-option:focus,
li.multiselect-option:focus-visible,
li.multiselect-option:focus-within,
li.multiselect-option:active,
.menu li.multiselect-option:focus,
.menu li.multiselect-option:active,
.menu-vertical li.multiselect-option:focus,
.menu-vertical li.multiselect-option:active {
background-color: transparent !important;
background: transparent !important;
color: inherit !important;
outline: none !important;
box-shadow: none !important;
}
/* Override DaisyUI menu internal opacity/color changes on child elements */
/* Exclude checkbox to preserve its checked color */
.multiselect-option:focus *:not(input[type="checkbox"]),
.multiselect-option:focus-visible *:not(input[type="checkbox"]),
.multiselect-option:active *:not(input[type="checkbox"]),
.multiselect-option.active *:not(input[type="checkbox"]),
.multiselect-option.menu-active *:not(input[type="checkbox"]),
li.multiselect-option:focus *:not(input[type="checkbox"]),
li.multiselect-option:active *:not(input[type="checkbox"]),
.menu li.multiselect-option:focus *:not(input[type="checkbox"]),
.menu li.multiselect-option:active *:not(input[type="checkbox"]),
.menu-vertical li.multiselect-option:focus *:not(input[type="checkbox"]),
.menu-vertical li.multiselect-option:active *:not(input[type="checkbox"]) {
opacity: 1 !important;
color: inherit !important;
background: transparent !important;
}
.multiselect-option:hover:not(.disabled) {
background-color: var(--color-base-200);
}
.multiselect-option.selected.highlight {
background-color: var(--color-primary);
color: var(--color-primary-content);
}
/* Color variants for selected items with highlight */
.multiselect-option.selected.highlight.color-secondary {
background-color: var(--color-secondary);
color: var(--color-secondary-content);
}
.multiselect-option.selected.highlight.color-accent {
background-color: var(--color-accent);
color: var(--color-accent-content);
}
.multiselect-option.selected.highlight.color-success {
background-color: var(--color-success);
color: var(--color-success-content);
}
.multiselect-option.selected.highlight.color-warning {
background-color: var(--color-warning);
color: var(--color-warning-content);
}
.multiselect-option.selected.highlight.color-error {
background-color: var(--color-error);
color: var(--color-error-content);
}
.multiselect-option.selected.highlight.color-info {
background-color: var(--color-info);
color: var(--color-info-content);
}
.multiselect-option.selected.highlight.color-neutral {
background-color: var(--color-neutral);
color: var(--color-neutral-content);
}
/* Keyboard navigation focus state */
.multiselect-option.focused > div {
outline: 2px solid oklch(var(--p));
outline-offset: -2px;
}
/* Clear button inside select */
.clear-btn-inner {
background: transparent;
border: none;
padding: 0.25rem;
cursor: pointer;
border-radius: 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.clear-btn-inner:hover {
background-color: rgba(0, 0, 0, 0.1);
}
/* Ensure button maintains flex layout */
.multiselect-trigger {
display: flex !important;
}
/* Show clear button on hover or when there are selections */
.multiselect-container:hover .clear-btn-inner.has-selections,
.clear-btn-inner.has-selections {
opacity: 0.6 !important;
}
.multiselect-container:hover .clear-btn-inner.has-selections:hover {
opacity: 1 !important;
}
/* Performance optimizations */
.multiselect-container {
contain: layout style;
}
.multiselect-dropdown {
contain: layout style;
will-change: transform;
}
.chips-container {
contain: layout style;
}
/* Hardware acceleration for smooth interactions */
.multiselect-option,
.badge,
.clear-btn-inner {
transform: translateZ(0);
backface-visibility: hidden;
-webkit-font-smoothing: subpixel-antialiased;
}
/* Optimize checkbox transitions */
.multiselect-option input[type="checkbox"] {
transition: none; /* Remove transition for instant feedback */
}
/* Smooth chip animations */
.badge {
transition: opacity 0.15s ease-out;
}
`;
document.head.appendChild(style);
}
/**
* @typedef {Object} SelectOption
* @property {string} value - The option value
* @property {string} text - The display text
* @property {boolean} selected - Whether the option is selected
* @property {boolean} disabled - Whether the option is disabled
* @property {HTMLElement} optionEl - The option DOM element
* @property {HTMLOptionElement} nativeOption - The native select option element
*/
/**
* @typedef {Object} SelectChangeEventDetail
* @property {string[]} values - Array of selected values
* @property {string} valueString - Semicolon-delimited string of values
*/
/**
* DaisyUI MultiSelect Custom Element
* A feature-rich multi-select dropdown component with DaisyUI styling
*
* @class DaisyMultiSelect
* @extends HTMLElement
*
* @fires change - Dispatched when selection changes (bubbles to native select)
*
* @example
* <daisy-multiselect placeholder="Select options..." searchable>
* <option value="1">Option 1</option>
* <option value="2" selected>Option 2</option>
* </daisy-multiselect>
*/
class DaisyMultiSelect extends HTMLElement {
// Class constants
static VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl'];
static VALID_COLORS = ['primary', 'secondary', 'accent', 'success', 'warning', 'error', 'info', 'neutral'];
static DEFAULT_SIZE = 'md';
static DEFAULT_COLOR = 'primary';
static DEFAULT_VIRTUAL_SCROLL_THRESHOLD = 50;
/**
* Debounce utility - delays function execution until after wait time has elapsed
* @param {Function} fn - The function to debounce
* @param {number} wait - The delay in milliseconds
* @returns {Function} The debounced function
* @static
*/
static debounce(fn, wait) {
let timeoutId;
return function debounced(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), wait);
};
}
/**
* Throttle utility - ensures function is called at most once per wait period
* @param {Function} fn - The function to throttle
* @param {number} wait - The minimum time between calls in milliseconds
* @returns {Function} The throttled function
* @static
*/
static throttle(fn, wait) {
let lastCall = 0;
return function throttled(...args) {
const now = Date.now();
if (now - lastCall >= wait) {
lastCall = now;
fn.apply(this, args);
}
};
}
constructor() {
super();
this._keysSelected = {};
this._valueDict = {};
this._isOpen = false;
this._optionIdCounter = 0; // For generating unique keys
this._updateDisplayPending = false; // For microtask batching (reactive system)
this._abortController = new AbortController(); // For event listener cleanup
this._selectedCount = 0; // Cache selected count for performance
this._cachedSelectedItems = []; // Cache selected items array to avoid rebuilding
this._selectedItemsDirty = true; // Track if cache needs refresh
// Debug mode
this._debug = false; // Enable debug logging with 'debug' or 'verbose' attribute
// Lifecycle hooks
this._hooks = {
onSetupStart: [],
onSetupComplete: [],
onRenderStart: [],
onRenderComplete: [],
onOptionAdded: [],
onOptionRemoved: [],
onSelectionChange: []
};
// Configuration options (can be set via configure() method)
this._customRenderer = null; // Custom renderer function for dropdown options
// Field mappings for addOptions bulk operation
this._valueField = 'value'; // Field name for option value
this._textField = 'text'; // Field name for option text
this._selectedField = 'selected'; // Field name for selected state
this._disabledField = 'disabled'; // Field name for disabled state
this._extraDataFields = []; // Array of field names to include as extra data
}
static get observedAttributes() {
// Observe class/style for user-applied styling and checked-color for checkbox colors
return ['class', 'style', 'checked-color'];
}
/**
* Centralized error handler
* @private
* @param {Error} error - The error object
* @param {string} context - Where the error occurred (method name)
*/
_handleError(error, context) {
console.error(`DaisyMultiSelect error in ${context}:`, error);
// Dispatch custom error event for developers to listen to
this.dispatchEvent(new CustomEvent('error', {
detail: { error, context, message: error.message },
bubbles: true,
composed: true
}));
// Show user-friendly error message if component is visible
if (this._triggerBtn && context !== 'connectedCallback') {
this._showErrorState(`An error occurred: ${error.message}`);
}
}
/**
* Show error state in the UI
* @private
* @param {string} message - Error message to display
*/
_showErrorState(message) {
if (!this._display) return;
const errorHtml = `
<span class="text-error flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm">${message}</span>
</span>
`;
this._display.innerHTML = errorHtml;
}
/**
* Extract data-* attributes from an element
* @private
* @param {HTMLElement} element - Element to extract data attributes from
* @returns {Object} Object containing all data attributes (keys without 'data-' prefix)
* @example
* // <option data-icon="??" data-rating="5" data-description="Red fruit">
* // Returns: { icon: '??', rating: '5', description: 'Red fruit' }
*/
_extractDataAttributes(element) {
const dataAttrs = {};
// Get all attributes from the element
Array.from(element.attributes).forEach(attr => {
// Check if attribute starts with 'data-'
if (attr.name.startsWith('data-')) {
// Remove 'data-' prefix and convert to camelCase
const key = attr.name.slice(5).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
dataAttrs[key] = attr.value;
}
});
return dataAttrs;
}
/**
* Log debug messages when debug mode is enabled
* @private
* @param {string} message - The message to log
* @param {...any} args - Additional arguments to log
*/
_log(message, ...args) {
if (this._debug) {
const id = this.id || this.getAttribute('name') || 'unnamed';
console.log(`[MultiSelect#${id}] ${message}`, ...args);
}
}
connectedCallback() {
// Enable debug mode if debug or verbose attribute is present
this._debug = this.hasAttribute('debug') || this.hasAttribute('verbose');
this._log('Component connecting...');
this._placeholder = this.getAttribute('placeholder') || 'Select options...';
this._color = this.getAttribute('checked-color') || DaisyMultiSelect.DEFAULT_COLOR;
this._isHexColor = this._color.startsWith('#');
this._chipTextColor = this.getAttribute('chip-text-color') || '';
this._inputColor = this.getAttribute('input-color') || '';
this._size = this.getAttribute('size') || DaisyMultiSelect.DEFAULT_SIZE;
this._singleSelect = this.hasAttribute('single-select');
this._maxSelections = this._singleSelect ? 1 : (parseInt(this.getAttribute('max-selections')) || 0);
this._searchable = this.hasAttribute('searchable');
this._showSelectAll = this.hasAttribute('show-select-all');
this._showClear = !this.hasAttribute('hide-clear'); // Default to true, unless hide-clear is specified
this._disabled = this.hasAttribute('disabled');
this._required = this.hasAttribute('required');
this._virtualScroll = this.hasAttribute('virtual-scroll');
this._virtualScrollThreshold = parseInt(this.getAttribute('virtual-scroll-threshold')) || DaisyMultiSelect.DEFAULT_VIRTUAL_SCROLL_THRESHOLD;
this._delimiter = this.getAttribute('delimiter') || ';';
// Chip-style is now true by default, can be disabled with no-chip-style attribute
this._chipStyle = !this.hasAttribute('no-chip-style');
// Highlight selected items with background color (default: false)
this._highlight = this.hasAttribute('highlight');
// Store initial classes and styles from the host element
this._userClasses = this.className;
this._userStyles = this.getAttribute('style') || '';
try {
// Apply custom hex color styles if needed
if (this._isHexColor || this._chipTextColor) {
this._applyHexColorStyles();
}
this._setupComponent();
this._setupEventHandlers();
this._setSelectedStates();
this._applyUserAttributes();
} catch (error) {
console.error('Failed to initialize daisy-multiselect:', error);
this.innerHTML = `
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Failed to initialize multi-select component</span>
</div>
`;
}
}
disconnectedCallback() {
this._removeEventHandlers();
// Remove custom hex color styles if they exist
if (this._hexColorStyleElement) {
this._hexColorStyleElement.remove();
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (!this._triggerBtn) return; // Not initialized yet
if (name === 'class') {
this._userClasses = newValue || '';
this._applyUserAttributes();
} else if (name === 'style') {
this._userStyles = newValue || '';
this._applyUserAttributes();
} else if (name === 'checked-color') {
this._color = newValue || 'primary';
this._isHexColor = this._color.startsWith('#');
if (this._isHexColor) {
this._applyHexColorStyles();
} else if (this._hexColorStyleElement) {
this._hexColorStyleElement.remove();
this._hexColorStyleElement = null;
}
this._applyCheckedColorToOptions();
this._applyUserAttributes();
}
}
_applyHexColorStyles() {
// Remove existing hex color styles if they exist
if (this._hexColorStyleElement) {
this._hexColorStyleElement.remove();
}
// Create unique class name for this instance
const uniqueId = `hex-${Math.random().toString(36).substr(2, 9)}`;
this._hexColorClass = uniqueId;
// Determine chip text color
const chipTextColor = this._chipTextColor || 'white';
// Create style element for custom colors
this._hexColorStyleElement = document.createElement('style');
let styles = '';
// Only add checkbox styles if using hex color for checkboxes
if (this._isHexColor) {
styles += `
.${uniqueId} .checkbox:checked,
.${uniqueId} .checkbox[checked="true"],
.${uniqueId} .checkbox[aria-checked="true"] {
--chkbg: ${this._color};
--chkfg: white;
border-color: ${this._color};
}
`;
}
// Add badge styles (for hex background color or custom text color)
if (this._isHexColor) {
// Custom background and text color
styles += `
.badge-hex-${uniqueId} {
background-color: ${this._color};
color: ${chipTextColor};
border-color: ${this._color};
}
`;
} else if (this._chipTextColor) {
// DaisyUI background with custom text color
styles += `
.badge-hex-${uniqueId} {
color: ${chipTextColor};
}
`;
}
this._hexColorStyleElement.textContent = styles;
document.head.appendChild(this._hexColorStyleElement);
}
_getInputColorClass() {
if (!this._inputColor) return '';
const validColors = ['neutral', 'primary', 'secondary', 'accent', 'info', 'success', 'warning', 'error'];
return validColors.includes(this._inputColor) ? `input-${this._inputColor}` : '';
}
_getSizeClass() {
// Return size class for consistent DaisyUI styling
// Heights match DaisyUI select: xs=1.5rem, sm=2rem, md=2.5rem, lg=3rem, xl=3.5rem
const validSizes = ['xs', 'sm', 'md', 'lg', 'xl'];
return validSizes.includes(this._size) ? `input-${this._size}` : 'input-md';
}
_getMinHeightClass() {
// Get minimum height class that matches DaisyUI select heights
// Using min-height to allow growth when chips are added
const sizeMap = {
'xs': 'min-h-6', // 1.5rem = 24px
'sm': 'min-h-8', // 2rem = 32px
'md': 'min-h-10', // 2.5rem = 40px
'lg': 'min-h-12', // 3rem = 48px
'xl': 'min-h-14' // 3.5rem = 56px
};
return sizeMap[this._size] || 'min-h-10';
}
_getHeightClass() {
// Get exact height class for use when no chips are selected (matches DaisyUI buttons/selects)
const sizeMap = {
'xs': 'h-6', // 1.5rem = 24px
'sm': 'h-8', // 2rem = 32px
'md': 'h-10', // 2.5rem = 40px
'lg': 'h-12', // 3rem = 48px
'xl': 'h-14' // 3.5rem = 56px
};
return sizeMap[this._size] || 'h-10';
}
_getCheckboxSizeClass() {
const sizeMap = {
'xs': 'checkbox-xs',
'sm': 'checkbox-sm',
'md': 'checkbox-md',
'lg': 'checkbox-lg',
'xl': 'checkbox-xl'
};
return sizeMap[this._size] || 'checkbox-md';
}
_getTextSizeClass() {
const sizeMap = {
'xs': 'text-xs',
'sm': 'text-sm',
'md': 'text-base',
'lg': 'text-lg',
'xl': 'text-xl'
};
return sizeMap[this._size] || 'text-base';
}
_getPaddingClass() {
const sizeMap = {
'xs': 'p-1',
'sm': 'p-1.5',
'md': 'p-2',
'lg': 'p-2.5',
'xl': 'p-3'
};
return sizeMap[this._size] || 'p-2';
}
/**
* Render option content using custom renderer or default
* @private
* @param {Object} item - Option data { value, text, selected, disabled, ...extraData }
* @returns {string} HTML string for option content (after checkbox)
*/
_renderOptionContent(item) {
// Use custom renderer if provided
if (this._customRenderer) {
try {
const customContent = this._customRenderer(item);
return customContent || `<span class="${this._getTextSizeClass()}">${this._escapeHtml(item.text)}</span>`;
} catch (error) {
console.error('Error in customRenderer:', error);
// Fall back to default
return `<span class="${this._getTextSizeClass()}">${this._escapeHtml(item.text)}</span>`;
}
}
// Default rendering
return `<span class="${this._getTextSizeClass()}">${this._escapeHtml(item.text)}</span>`;
}
_setupComponent() {
this._log('Setting up component...');
// Store original select element
const options = Array.from(this.querySelectorAll('option'));
this._log(`Found ${options.length} option elements`);
// Create wrapper
this.innerHTML = `
<div class="relative w-full">
<!-- Hidden Native Select -->
<select multiple class="hidden" name="${this.getAttribute('name') || 'multiselect'}">
${options.map(opt => `
<option value="${opt.value}" ${opt.selected ? 'selected' : ''} ${opt.disabled ? 'disabled' : ''}>
${opt.textContent}
</option>
`).join('')}
</select>
<!-- Display Input -->
<div class="multiselect-container flex flex-row items-center">
<button type="button" class="multiselect-trigger input input-bordered w-full text-left cursor-pointer !h-auto ${this._getMinHeightClass()} flex ${this._chipStyle ? 'flex-wrap gap-x-2 !p-1.5' : 'justify-between'} ${this._getInputColorClass()} ${this._getSizeClass()} ${this._disabled ? 'input-disabled cursor-not-allowed opacity-60' : ''}" ${this._disabled ? 'disabled' : ''} aria-haspopup="listbox" aria-expanded="false" aria-label="${this._placeholder}" role="combobox">
<span class="multiselect-display ${this._chipStyle ? 'flex-1 min-w-0' : 'flex-1 mr-2 py-1'}">${this._placeholder}</span>
<div class="flex items-center gap-2 flex-shrink-0">
${this._showClear ? `
<span class="multiselect-clear-btn clear-btn-inner opacity-0 hover:opacity-100 transition-opacity ${this._disabled ? 'hidden' : ''}" title="Clear all selections">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</span>
` : ''}
<svg class="multiselect-caret w-5 h-5 transition-transform flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</button>
</div>
<!-- Dropdown Content -->
<div class="multiselect-dropdown-content dropdown-content menu bg-base-200 rounded-box z-50 w-full shadow-lg border border-base-300 hidden multiselect-dropdown absolute" role="listbox" aria-multiselectable="true" style="top: 100%; margin-top: 0.5rem; contain: layout style paint;">
<div class="p-2">
${this._searchable ? `
<div class="mb-2">
<input type="search" placeholder="Search..." class="multiselect-search-input input ${this._getSizeClass()} input-bordered w-full">
</div>
` : ''}
${this._showSelectAll ? `
<div class="flex gap-2 mb-2">
<button type="button" class="multiselect-select-all-btn btn ${this._getSizeClass().replace('input-', 'btn-')} btn-ghost flex-1">Select All</button>
<button type="button" class="multiselect-deselect-all-btn btn ${this._getSizeClass().replace('input-', 'btn-')} btn-ghost flex-1">Deselect All</button>
</div>
` : ''}
<ul class="multiselect-options-list menu-vertical w-full ${this._isHexColor ? this._hexColorClass : ''}">
${options.map((opt, index) => {
// Extract all data-* attributes from the option element
const extraData = this._extractDataAttributes(opt);
// Override selected/disabled if data attributes present
const selected = extraData.selected !== undefined ? extraData.selected === 'true' || extraData.selected === true : opt.selected;
const disabled = extraData.disabled !== undefined ? extraData.disabled === 'true' || extraData.disabled === true : opt.disabled;
const itemData = {
value: opt.value,
text: opt.textContent,
selected: selected,
disabled: disabled,
index: index,
...extraData // Include all data-* attributes
};
// Build data attributes string from extraData (excluding selected/disabled as they're handled separately)
let dataAttributesHtml = '';
for (const [key, value] of Object.entries(extraData)) {
if (key !== 'selected' && key !== 'disabled') {
// Convert camelCase back to kebab-case for HTML attributes
const kebabKey = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
dataAttributesHtml += ` data-${kebabKey}="${value}"`;
}
}
return `
<li class="multiselect-option w-full ${disabled ? 'disabled opacity-50' : ''} active:!bg-transparent focus:!bg-transparent focus:outline-none" data-index="${index}" data-value="${opt.value}" data-text="${opt.textContent.toLowerCase()}"${dataAttributesHtml} role="option" aria-selected="${selected ? 'true' : 'false'}" ${disabled ? 'aria-disabled="true"' : ''} tabindex="-1">
<div class="flex items-center gap-3 cursor-pointer w-full ${this._getPaddingClass()} rounded ${disabled ? 'pointer-events-none' : ''} active:!bg-transparent active:scale-100">
<input type="checkbox" class="checkbox ${this._getCheckboxColorClass()} ${this._getCheckboxSizeClass()} pointer-events-none" ${selected ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
${this._renderOptionContent(itemData)}
</div>
</li>
`;
}).join('')}
</ul>
<div class="multiselect-no-results text-center text-sm opacity-60 hidden mt-2">No options found</div>
</div>
</div>
</div>
`;
// Store references (cache for performance)
this._triggerBtn = this.querySelector('.multiselect-trigger');
this._dropdown = this.querySelector('.multiselect-dropdown-content');
this._display = this.querySelector('.multiselect-display');
this._caret = this.querySelector('.multiselect-caret');
this._nativeSelect = this.querySelector('select');
this._options = this.querySelectorAll('.multiselect-option');
this._clearBtn = this.querySelector('.multiselect-clear-btn'); // Cache clear button
// Add will-change hints for animated elements (GPU acceleration)
if (this._caret) {
this._caret.style.willChange = 'transform';
}
if (this._dropdown) {
this._dropdown.style.willChange = 'opacity, transform';
}
// Setup delegated event listener for chip removal (performance optimization)
this._display.addEventListener('click', (e) => {
const removeBtn = e.target.closest('[data-chip-remove]');
if (removeBtn) {
e.preventDefault();
e.stopPropagation();
const key = removeBtn.dataset.chipRemove;
this._removeChip(key);
}
}, { signal: this._abortController.signal });
// Build value dictionary
options.forEach((opt, index) => {
const key = `option-${this._optionIdCounter++}`;
const optionEl = this._options[index];
optionEl.dataset.optionKey = key; // Store key on element
// Extract data attributes
const extraData = this._extractDataAttributes(opt);
const selected = extraData.selected !== undefined ? extraData.selected === 'true' || extraData.selected === true : opt.selected;
const disabled = extraData.disabled !== undefined ? extraData.disabled === 'true' || extraData.disabled === true : opt.disabled;
this._valueDict[key] = {
value: opt.value,
text: opt.textContent,
selected: selected,
disabled: disabled,
optionEl: optionEl,
nativeOption: this._nativeSelect.options[index],
...extraData // Store all data-* attributes for custom renderer
};
});
// Setup virtual scrolling if enabled and threshold is met
if (this._virtualScroll && options.length > this._virtualScrollThreshold) {
this._setupVirtualScroll();
}
this._log('Component setup complete');
this._trigger('onSetupComplete', { optionCount: options.length });
}
_applyUserAttributes() {
if (!this._triggerBtn) return;
// Get the current internal classes that should be preserved
const internalClasses = [
'multiselect-trigger',
'input',
'input-bordered',
'w-full',
'text-left',
'cursor-pointer',
this._getInputColorClass(),
this._getSizeClass()
];
// Add layout classes - both modes can grow vertically
internalClasses.push('!h-auto', this._getMinHeightClass(), 'flex');
// Add chip-style specific classes
if (this._chipStyle) {
internalClasses.push('flex-wrap', 'gap-x-2', '!p-1.5');
} else {
internalClasses.push('justify-between');
}
// Add disabled classes if applicable
if (this._disabled) {
internalClasses.push('input-disabled', 'cursor-not-allowed', 'opacity-60');
}
// Split user classes and filter out empty strings
const userClassList = this._userClasses ? this._userClasses.split(/\s+/).filter(c => c) : [];
// Combine internal and user classes
const allClasses = [...internalClasses, ...userClassList].filter(c => c);
// Apply combined classes
this._triggerBtn.className = allClasses.join(' ');
// Apply user styles
if (this._userStyles) {
// Parse and apply each style property
const styleProps = this._userStyles.split(';').filter(s => s.trim());
styleProps.forEach(prop => {
const [key, value] = prop.split(':').map(s => s.trim());
if (key && value) {
this._triggerBtn.style[key] = value;
}
});
}
// Copy data-* attributes from host to trigger
const componentAttributes = Array.from(this.attributes);
const reservedAttributes = [
'placeholder', 'color', 'checked-color', 'input-color', 'size', 'max-selections',
'searchable', 'show-select-all', 'hide-clear', 'disabled', 'required',
'virtual-scroll', 'virtual-scroll-threshold', 'delimiter', 'name',
'class', 'style'
];
componentAttributes.forEach(attr => {
// Copy data-* attributes and other non-reserved attributes
if (attr.name.startsWith('data-') ||
(!reservedAttributes.includes(attr.name) &&
attr.name !== 'id' &&
!attr.name.startsWith('aria-'))) {
this._triggerBtn.setAttribute(attr.name, attr.value);
}
});
}
/**
* Apply the current checked color to checkboxes and selected option styling
* @private
*/
_applyCheckedColorToOptions() {
// Update checkbox classes for existing options
try {
// Update hex color class on container if needed
const optionsList = this._dropdown.querySelector('.multiselect-options-list');
if (optionsList) {
if (this._isHexColor) {
optionsList.classList.add(this._hexColorClass);
} else {
// Remove any hex color classes
optionsList.className = optionsList.className.replace(/hex-\w+/g, '').trim();
}
}
for (let key in this._valueDict) {
const item = this._valueDict[key];
if (!item || !item.optionEl) continue;
// Update checkbox element class
const checkbox = item.optionEl.querySelector('input[type="checkbox"]');
if (checkbox) {
checkbox.className = `checkbox ${this._getCheckboxColorClass()} ${this._getCheckboxSizeClass()} pointer-events-none`;
}
// Update selected option color styling
if (this._keysSelected[key]) {
if (this._color !== 'primary' && !this._isHexColor) {
item.optionEl.classList.add(`color-${this._color}`);
} else {
item.optionEl.classList.remove(...Array.from(item.optionEl.classList).filter(c => c.startsWith('color-')));
}
} else {
// Remove any color- classes from unselected items if present
item.optionEl.classList.remove(...Array.from(item.optionEl.classList).filter(c => c.startsWith('color-')));
}
}
} catch (err) {
console.error('Error applying checked color to options', err);
}
}
_setupVirtualScroll() {
this._virtualScrollEnabled = true;
this._itemHeight = 40; // Approximate height per item
this._visibleItems = Math.ceil(300 / this._itemHeight); // Based on max-height of 300px
this._bufferItems = 5; // Extra items to render above/below viewport
this._scrollTop = 0;
const optionsList = this.querySelector('.multiselect-options-list');
const dropdown = this._dropdown;
// Create scroll container
this._allOptions = Array.from(this._options);
this._totalItems = this._allOptions.length;
// Set container height
const totalHeight = this._totalItems * this._itemHeight;
optionsList.style.height = `${totalHeight}px`;
optionsList.style.position = 'relative';
// Add scroll listener with passive flag for better performance
this._virtualScrollBound = this._handleVirtualScroll.bind(this);
dropdown.addEventListener('scroll', this._virtualScrollBound, { passive: true, signal: this._abortController.signal });
// Initial render
this._renderVisibleItems();
}
_handleVirtualScroll() {
this._scrollTop = this._dropdown.scrollTop;
this._renderVisibleItems();
}
_renderVisibleItems() {
if (!this._virtualScrollEnabled) return;
const startIndex = Math.max(0, Math.floor(this._scrollTop / this._itemHeight) - this._bufferItems);
const endIndex = Math.min(this._totalItems, startIndex + this._visibleItems + (this._bufferItems * 2));
// Hide all items
this._allOptions.forEach((option, index) => {
if (index < startIndex || index >= endIndex) {
option.style.display = 'none';
} else {
option.style.display = '';
option.style.position = 'absolute';
option.style.top = `${index * this._itemHeight}px`;
option.style.width = '100%';
}
});
}
_disableVirtualScroll() {
if (!this._virtualScrollEnabled) return;
// Show all options
this._allOptions.forEach((option) => {
option.style.display = '';
option.style.position = '';
option.style.top = '';
});
const optionsList = this.querySelector('.multiselect-options-list');
optionsList.style.height = '';
optionsList.style.position = '';
}
_enableVirtualScroll() {
if (!this._virtualScroll || this._totalItems < this._virtualScrollThreshold) return;
// Reset to virtual scroll mode
this._scrollTop = this._dropdown.scrollTop;
const optionsList = this.querySelector('.multiselect-options-list');
const totalHeight = this._totalItems * this._itemHeight;
optionsList.style.height = `${totalHeight}px`;
optionsList.style.position = 'relative';
this._renderVisibleItems();
}
_setupEventHandlers() {
const signal = this._abortController.signal;
// Toggle dropdown
this._toggleBound = this._toggleDropdown.bind(this);
this._triggerBtn.addEventListener('click', this._toggleBound, { signal });
// Handle option clicks with event delegation (single listener instead of N listeners)
this._optionClickBound = this._handleOptionClickDelegated.bind(this);
const optionsList = this.querySelector('.multiselect-options-list');
if (optionsList) {
optionsList.addEventListener('click', this._optionClickBound, { signal });
}
// Handle search input with debouncing for better performance
const searchInput = this.querySelector('.multiselect-search-input');
if (searchInput) {
// Debounce search for 150ms to reduce DOM operations during typing
const debouncedFilter = DaisyMultiSelect.debounce((value) => {
this._filterOptions(value);
}, 150);
searchInput.addEventListener('input', (e) => {
debouncedFilter(e.target.value);
}, { signal });
searchInput.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent dropdown from closing
}, { signal });
}
// Handle select all button
const selectAllBtn = this.querySelector('.multiselect-select-all-btn');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.selectAll();
}, { signal });
}
// Handle deselect all button
const deselectAllBtn = this.querySelector('.multiselect-deselect-all-btn');
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.clearSelected();
}, { signal });
}
// Handle clear button
const clearBtn = this.querySelector('.multiselect-clear-btn');
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.clearSelected();
}, { signal });
}
// Setup global click handler for closing dropdowns (shared across all components for performance)
this._setupGlobalClickHandler();
// Handle keyboard navigation
this._keydownBound = this._handleKeydown.bind(this);
this._triggerBtn.addEventListener('keydown', this._keydownBound, { signal });
}
_setupGlobalClickHandler() {
// Use a single global click handler for all multiselect components
// This prevents N event handlers from being registered when there are N components
if (!window._multiselectGlobalClickHandler) {
window._multiselectGlobalClickHandler = (e) => {
// Find all multiselect components and close any that should close
document.querySelectorAll('daisy-multiselect').forEach(component => {
if (component._isOpen && !component.contains(e.target)) {
component.close();
}
});
};
document.addEventListener('click', window._multiselectGlobalClickHandler, { passive: true });
}
// Mark this component as using the global handler
this._usesGlobalClickHandler = true;
}
_removeEventHandlers() {
// AbortController removes all event listeners at once
this._abortController.abort();
// Create a new AbortController for potential re-initialization
this._abortController = new AbortController();
}
_toggleDropdown(e) {
e.stopPropagation();
if (this._isOpen) {
this.close();
} else {
this.open();
}
}
_positionDropdown() {
if (!this._triggerBtn || !this._dropdown) return;
// Wait for next frame to ensure dropdown is rendered
requestAnimationFrame(() => {
// Get viewport height and element position
const viewportHeight = window.innerHeight;
const triggerRect = this._triggerBtn.getBoundingClientRect();
const dropdownHeight = this._dropdown.offsetHeight;
// Calculate space above and below
const spaceBelow = viewportHeight - triggerRect.bottom;
const spaceAbove = triggerRect.top;
// Decide whether to open upward or downward
const shouldOpenUpward = spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
if (shouldOpenUpward) {
// Position above
this._dropdown.style.bottom = '100%';
this._dropdown.style.top = 'auto';
this._dropdown.style.marginTop = '0';
this._dropdown.style.marginBottom = '0.5rem';
} else {
// Position below (default)
this._dropdown.style.top = '100%';
this._dropdown.style.bottom = 'auto';
this._dropdown.style.marginTop = '0.5rem';
this._dropdown.style.marginBottom = '0';
}
});
}
_handleKeydown(e) {
if (e.key === 'Escape' && this._isOpen) {
e.preventDefault();
this.close();
} else if (this._isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
this._navigateOptions(e.key === 'ArrowDown' ? 1 : -1);
} else if (this._isOpen && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
const focusedOption = Array.from(this._options).find(opt => opt.classList.contains('focused'));
if (focusedOption) {
// If there's a focused option, select it
this._selectFocusedOption();
} else {
// If no focused option, close the dropdown
this.close();
}
} else if (!this._isOpen && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
this.open();
}
}
/**
* Event delegation handler for option clicks
* Single listener for all options instead of N individual listeners
* @private
*/
_handleOptionClickDelegated(e) {
try {
// Find the closest option element (event delegation)
const option = e.target.closest('.multiselect-option');
// Ignore clicks outside options or on disabled options
if (!option || option.classList.contains('disabled')) {
return;
}
e.preventDefault();
e.stopPropagation();
const key = option.dataset.optionKey;
// Provide immediate visual feedback before processing
const checkbox = option.querySelector('input[type="checkbox"]');
const willBeSelected = !checkbox.checked;
// Immediate checkbox toggle for perceived performance
checkbox.checked = willBeSelected;
// Then process the selection
this._toggleSelection(key);
// Note: Change event is dispatched by _performDisplayUpdate, not here
} catch (error) {
this._handleError(error, '_handleOptionClickDelegated');
}
}
/**
* Legacy handler - kept for backward compatibility
* @deprecated Use _handleOptionClickDelegated instead
* @private
*/
_handleOptionClick(e) {
e.preventDefault();
e.stopPropagation();
const option = e.currentTarget;
const key = option.dataset.optionKey;
// Provide immediate visual feedback before processing
const checkbox = option.querySelector('input[type="checkbox"]');
const willBeSelected = !checkbox.checked;
// Immediate checkbox toggle for perceived performance
checkbox.checked = willBeSelected;
// Then process the selection
this._toggleSelection(key);
// Note: Change event is dispatched by _performDisplayUpdate, not here
}
_toggleSelection(key) {
const item = this._valueDict[key];
if (!item || item.disabled) return;
const isSelected = !this._keysSelected[key];
// Check max selections limit (use cached count for performance)
if (isSelected && this._maxSelections > 0 && this._selectedCount >= this._maxSelections) {
return; // Don't allow more selections
}
if (isSelected) {
this._keysSelected[key] = true;
this._selectedCount++;
} else {
delete this._keysSelected[key];
this._selectedCount--;
}
// Mark selected items cache as dirty
this._selectedItemsDirty = true;
// Batch DOM updates for better performance
// Use cached checkbox reference if available
const checkbox = item.checkbox || item.optionEl.querySelector('input[type="checkbox"]');
if (!item.checkbox) item.checkbox = checkbox; // Cache for future use
checkbox.checked = isSelected;
// Update ARIA
item.optionEl.setAttribute('aria-selected', isSelected ? 'true' : 'false');
// Batch class operations
const classList = item.optionEl.classList;
if (isSelected) {
classList.add('selected');
if (this._highlight) {
classList.add('highlight');
}
if (this._color !== 'primary') {
classList.add(`color-${this._color}`);
}
} else {
classList.remove('selected', 'highlight', `color-${this._color}`);
}
// Update native select
item.nativeOption.selected = isSelected;
// Update display (already batched with requestAnimationFrame)
this._updateDisplay();
// Trigger lifecycle hook (optimized - use count instead of creating array)
this._trigger('onSelectionChange', {
selectedCount: this._selectedCount,
key,
value: item.value,
text: item.text,
isSelected
});
// Defer max selections state update if needed
if (this._maxSelections > 0) {
this._scheduleMaxSelectionsUpdate();
}
}
_setSelectedStates() {
this._keysSelected = {};
this._selectedCount = 0; // Reset count
this._selectedItemsDirty = true; // Mark cache dirty
for (let key in this._valueDict) {
const item = this._valueDict[key];
if (item.selected) {
this._keysSelected[key] = true;
this._selectedCount++; // Increment count
item.optionEl.classList.add('selected');
if (this._highlight) {
item.optionEl.classList.add('highlight');
}
if (this._color !== 'primary') {
item.optionEl.classList.add(`color-${this._color}`);
}
const checkbox = item.optionEl.querySelector('input[type="checkbox"]');
checkbox.checked = true;
}
}
this._updateDisplay();
}
_updateDisplay() {
if (!this._display) return;
// Reactive system: Batch updates using microtasks (immediate, but coalesced)
// This provides instant feedback while preventing layout thrashing
if (this._updateDisplayPending) return;
this._updateDisplayPending = true;
// Use queueMicrotask for immediate batching (faster than setTimeout/rAF)
queueMicrotask(() => {
this._updateDisplayPending = false;
this._performDisplayUpdate();
});
}
_performDisplayUpdate() {
if (!this._display) return;
// Use cached selected items if available, rebuild if dirty
if (this._selectedItemsDirty) {
this._cachedSelectedItems = [];
for (let key in this._keysSelected) {
const item = this._valueDict[key];
if (item) {
this._cachedSelectedItems.push({ key, text: item.text, value: item.value });
}
}
this._selectedItemsDirty = false;
}
// Update clear button visibility using cached reference and count
if (this._clearBtn) {
if (this._selectedCount === 0) {
this._clearBtn.classList.remove('has-selections');
} else {
this._clearBtn.classList.add('has-selections');
}
}
if (this._chipStyle) {
this._renderChips(this._cachedSelectedItems);
} else {
this._renderCommaList(this._cachedSelectedItems);
}
// Update border color based on blank state
this._updateBorderColor();
// Dispatch change event (uses cached items)
this._dispatchChangeEvent();
}
/**
* Render selected items as comma-separated text (default mode)
* @private
*/
_renderCommaList(selectedItems) {
if (selectedItems.length === 0) {
const newText = this._placeholder;
// Only update text if changed (prevents flicker), but always ensure correct opacity
if (this._display.textContent !== newText) {
this._display.textContent = newText;
}
this._display.classList.add('opacity-60');
} else {
const selectedTexts = selectedItems.map(item => item.text);
let displayText = selectedTexts.join(', ');
// Add max selections indicator if limit is set (use cached count)
if (this._maxSelections > 0) {
displayText = `${displayText} (${this._selectedCount}/${this._maxSelections})`;
}
// Only update text if changed (prevents flicker), but always ensure correct opacity
if (this._display.textContent !== displayText) {
this._display.textContent = displayText;
}
this._display.classList.remove('opacity-60');
}
}
/**
* Render selected items as removable chips/badges (chip-style mode)
* Following Choices.js pattern: show all chips, let container grow vertically
* Optimized to only update changed chips instead of recreating all
* @private
*/
_renderChips(selectedItems) {
this._display.classList.remove('opacity-60');
if (selectedItems.length === 0) {
// Show placeholder
this._display.innerHTML = '';
const placeholder = document.createElement('span');
placeholder.textContent = this._placeholder;
placeholder.className = 'opacity-60';
this._display.appendChild(placeholder);
return;
}
// Get or create chips container
let chipsContainer = this._display.querySelector('.chips-container');
if (!chipsContainer) {
this._display.innerHTML = '';
chipsContainer = document.createElement('div');
chipsContainer.className = 'chips-container flex flex-wrap gap-1 items-start w-full';
chipsContainer.style.contain = 'layout style'; // CSS containment for better performance
this._display.appendChild(chipsContainer);
}
// Create a map of existing chips for faster lookup
const existingChips = new Map();
chipsContainer.querySelectorAll('[data-item-key]').forEach(chip => {
existingChips.set(chip.dataset.itemKey, chip);
});
// Create a set of current keys
const currentKeys = new Set(selectedItems.map(item => item.key));
// Batch DOM operations using DocumentFragment for better performance
const fragment = document.createDocumentFragment();
const chipsToRemove = [];
// Mark chips for removal
existingChips.forEach((chip, key) => {
if (!currentKeys.has(key)) {
chipsToRemove.push(chip);
}
});
// Add new chips to fragment (only create if doesn't exist)
selectedItems.forEach(({ key, text }) => {
if (!existingChips.has(key)) {
const chip = this._createChip(key, text);
fragment.appendChild(chip);
}
});
// Batch remove old chips (single reflow)
chipsToRemove.forEach(chip => chip.remove());
// Batch add new chips (single reflow)
if (fragment.hasChildNodes()) {
chipsContainer.appendChild(fragment);
}
}
/**
* Create a chip element for a selected item
* @private
*/
_createChip(key, text) {
const chip = document.createElement('div');
chip.className = `badge ${this._getBadgeColorClass()} gap-0 ${this._getChipSizeClass()} flex items-center justify-center rounded-full ${this._disabled ? '!px-3' : '!px-0'}`;
chip.dataset.itemKey = key;
// Use innerHTML for faster rendering
if (this._disabled) {
chip.innerHTML = `<span class="px-3">${this._escapeHtml(text)}</span>`;
} else {
// Cache the remove button SVG to avoid repeated string parsing
if (!this._removeSvgCache) {
this._removeSvgCache = `<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>`;
}
chip.innerHTML = `
<span class="px-2">${this._escapeHtml(text)}</span>
<span class="w-px h-4 bg-current opacity-30"></span>
<button type="button" class="hover:opacity-75 transition-opacity cursor-pointer px-2" aria-label="Remove ${this._escapeHtml(text)}" data-chip-remove="${key}">
${this._removeSvgCache}
</button>
`;
// Note: Click events are handled by delegated listener on display container (performance optimization)
}
return chip;
}
/**
* Escape HTML to prevent XSS
* @private
*/
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Get appropriate badge size class based on component size
* @private
*/
_getChipSizeClass() {
const sizeMap = {
'xs': 'badge-xs',
'sm': 'badge-sm',
'md': 'badge-md',
'lg': 'badge-lg',
'xl': 'badge-lg' // DaisyUI doesn't have badge-xl, use lg
};
return sizeMap[this._size] || 'badge-md';
}
/**
* Get checkbox color class
* @private
*/
_getCheckboxColorClass() {
return this._isHexColor ? '' : `checkbox-${this._color}`;
}
/**
* Get badge color class for chips
* @private
*/
_getBadgeColorClass() {
// Build class list
let classes = [];
// Add DaisyUI color class if not using hex background
if (!this._isHexColor) {
classes.push(`badge-${this._color}`);
}
// Add custom class if using hex color or custom text color
if (this._isHexColor || this._chipTextColor) {
classes.push(`badge-hex-${this._hexColorClass}`);
}
return classes.join(' ') || `badge-${this._color}`;
}
/**
* Remove a chip by toggling its selection
* @private
*/
_removeChip(key) {
if (!this._disabled) {
this._toggleSelection(key);
// Note: Change event is dispatched by _performDisplayUpdate
}
}
_updateBorderColor() {
if (!this._triggerBtn) return;
// Remove existing border color classes
this._triggerBtn.classList.remove('border-error', 'border-success');
// Only apply validation colors if required is true
if (this._required) {
if (this.isBlank()) {
this._triggerBtn.classList.add('border-error');
} else {
this._triggerBtn.classList.add('border-success');
}
}
}
_dispatchChangeEvent() {
// Use cached selected items to avoid another iteration
const selectedValues = this._cachedSelectedItems.map(item => item.value);
const valueString = selectedValues.join(this._delimiter);
const event = new CustomEvent('change', {
detail: { values: selectedValues, valueString: valueString },
bubbles: true
});
this.dispatchEvent(event);
}
// Public API
/**
* Get array of currently selected values
* @returns {string[]} Array of selected option values
* @public
* @example
* const values = multiselect.getSelectedValues();
* console.log(values); // ['option1', 'option2']
*/
getSelectedValues() {
const values = [];
for (let key in this._keysSelected) {
const item = this._valueDict[key];
if (item) {
values.push(item.value);
}
}
return values;
}
getSelectedTexts() {
const texts = [];
for (let key in this._keysSelected) {
const item = this._valueDict[key];
if (item) {
texts.push(item.text);
}
}
return texts;
}
/**
* Clear all selected items (deselect everything)
*/
clearSelected() {
for (let key in this._keysSelected) {
this._toggleSelection(key);
}
}
/**
* Remove all options from the select (clears the entire list)
* @public
* @example
* multiselect.clear(); // Removes all options
*/
clear() {
// Get all option values to remove
const allValues = Object.values(this._valueDict).map(item => item.value);
// Remove each option
allValues.forEach(value => {
this.removeOption(value);
});
// Clear any remaining state
this._keysSelected = {};
this._selectedCount = 0; // Reset count
this._selectedItemsDirty = true; // Mark cache dirty
this._valueDict = {};
this._updateDisplay();
}
selectAll() {
for (let key in this._valueDict) {
if (!this._valueDict[key].disabled && !this._keysSelected[key]) {
// Respect max selections limit (use cached count)
if (this._maxSelections > 0 && this._selectedCount >= this._maxSelections) {
break; // Stop when limit is reached
}
this._toggleSelection(key);
}
}
}
/**
* Add a new option to the select
* @param {string} value - The option value
* @param {string} text - The option display text
* @param {boolean} [selected=false] - Whether the option should be selected
* @param {boolean} [disabled=false] - Whether the option should be disabled
* @param {Object} [extraData={}] - Additional data for custom renderer (e.g., description, icon, badge)
* @returns {boolean} True if option was added successfully, false otherwise
* @public
* @example
* multiselect.addOption('apple', 'Apple', false, false);
* multiselect.addOption('banana', 'Banana', true); // Pre-selected
* multiselect.addOption('mango', 'Mango', false, false, { description: 'Tropical fruit', icon: '??' });
*/
addOption(value, text, selected = false, disabled = false, extraData = {}) {
try {
// Validate parameters
if (typeof value !== 'string' || value === '') {
console.error('addOption: value must be a non-empty string');
return false;
}
if (typeof text !== 'string' || text === '') {
console.error('addOption: text must be a non-empty string');
return false;
}
// Check if value already exists
const existingKey = this._findKeyByValue(value);
if (existingKey) {
console.warn(`Option with value "${value}" already exists`);
return false;
}
// Add to native select
const nativeOption = document.createElement('option');
nativeOption.value = value;
nativeOption.textContent = text; // Safe - uses textContent
nativeOption.selected = selected;
nativeOption.disabled = disabled;
this._nativeSelect.appendChild(nativeOption);
// Create list item using DOM methods to avoid XSS
const li = document.createElement('li');
const key = `option-${this._optionIdCounter++}`;
const index = this._nativeSelect.options.length - 1;
li.className = `multiselect-option w-full ${disabled ? 'disabled opacity-50' : ''}`;
li.dataset.index = index;
li.dataset.value = value;
li.dataset.text = text.toLowerCase(); // For search functionality
li.dataset.optionKey = key;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', selected ? 'true' : 'false');
if (disabled) {
li.setAttribute('aria-disabled', 'true');
}
// Create inner div
const div = document.createElement('div');
div.className = `flex items-center gap-3 cursor-pointer w-full ${this._getPaddingClass()} rounded ${disabled ? 'pointer-events-none' : ''}`;
// Create checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = `checkbox ${this._getCheckboxColorClass()} ${this._getCheckboxSizeClass()} pointer-events-none`;
checkbox.checked = selected;
checkbox.disabled = disabled;
// Prepare item data for renderer
const itemData = {
value: value,
text: text,
selected: selected,
disabled: disabled,
index: index,
...extraData // Include any extra data (description, icon, badge, etc.)
};
// Render content using custom renderer or default
const contentHtml = this._renderOptionContent(itemData);
// Assemble elements - append checkbox first
div.appendChild(checkbox);
// Create temporary container to parse HTML without destroying checkbox
const tempContainer = document.createElement('div');
tempContainer.innerHTML = contentHtml;
// Move all parsed nodes from temp container to div (preserves checkbox)
while (tempContainer.firstChild) {
div.appendChild(tempContainer.firstChild);
}
li.appendChild(div);
// Add to dropdown
const ul = this.querySelector('.multiselect-options-list');
if (!ul) {
console.error('addOption: options list not found');
return false;
}
ul.appendChild(li);
// Update value dictionary (including extra data for custom renderer)
this._valueDict[key] = {
value: value,
text: text,
selected: selected,
disabled: disabled,
optionEl: li,
nativeOption: nativeOption,
...extraData // Store extra data for future re-renders
};
// No need to add individual listener - using event delegation on parent
// Handle selection
if (selected) {
this._keysSelected[key] = true;
this._selectedCount++; // Update count
this._selectedItemsDirty = true; // Mark cache dirty
li.classList.add('selected');
if (this._highlight) {
li.classList.add('highlight');
}
if (this._color !== 'primary') {
li.classList.add(`color-${this._color}`);
}
}
// Update cached options list reference
this._options = this.querySelectorAll('.multiselect-option');
this._updateDisplay();
// Trigger lifecycle hook
this._trigger('onOptionAdded', {
key,
text,
value,
selected,
disabled,
extraData
});
// Note: Change event is dispatched by _performDisplayUpdate
return true;
} catch (error) {
this._handleError(error, 'addOption');
return false;
}
}
/**
* Add multiple options in bulk (optimized for performance)
* @param {Array<Object>} items - Array of objects to add as options
* @param {Object} [fieldConfig] - Optional field name mappings
* @param {string} [fieldConfig.valueField='value'] - Name of the field containing option value
* @param {string} [fieldConfig.textField='text'] - Name of the field containing option text
* @param {string} [fieldConfig.selectedField='selected'] - Name of the field containing selected state
* @param {string} [fieldConfig.disabledField='disabled'] - Name of the field containing disabled state
* @param {string[]} [fieldConfig.extraDataFields=[]] - Array of field names to include as extra data
* @returns {number} Number of options successfully added
* @public
* @example
* // Basic usage with default field names
* multiselect.addOptions([
* { value: 'apple', text: 'Apple', selected: false },
* { value: 'banana', text: 'Banana', selected: true }
* ]);
*
* @example
* // Custom field names
* multiselect.addOptions(
* [
* { id: 'user1', name: 'John Doe', active: true, role: 'Admin', email: 'john@example.com' },
* { id: 'user2', name: 'Jane Smith', active: false, role: 'User', email: 'jane@example.com' }
* ],
* {
* valueField: 'id',
* textField: 'name',
* selectedField: 'active',
* disabledField: null, // No disabled field
* extraDataFields: ['role', 'email'] // Include these as extra data for custom renderer
* }
* );
*/
addOptions(items, fieldConfig = {}) {
try {
// Validate input
if (!Array.isArray(items)) {
console.error('addOptions: items must be an array');
return 0;
}
if (items.length === 0) {
return 0;
}
// Apply field configuration (use provided config or instance defaults)
const valueField = fieldConfig.valueField || this._valueField;
const textField = fieldConfig.textField || this._textField;
const selectedField = fieldConfig.selectedField || this._selectedField;
const disabledField = fieldConfig.disabledField !== undefined ? fieldConfig.disabledField : this._disabledField;
const extraDataFields = fieldConfig.extraDataFields || this._extraDataFields;
// Build all elements in memory first (batch DOM operations)
const itemsToAdd = [];
const fragment = document.createDocumentFragment();
const nativeFragment = document.createDocumentFragment();
let hasSelectedItems = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Extract values using field mappings
const value = item[valueField];
const text = item[textField];
const selected = selectedField ? (item[selectedField] || false) : false;
const disabled = disabledField ? (item[disabledField] || false) : false;
// Validate required fields
if (typeof value !== 'string' || value === '') {
console.warn(`addOptions: Skipping item at index ${i} - invalid value field`);
continue;
}
if (typeof text !== 'string' || text === '') {
console.warn(`addOptions: Skipping item at index ${i} - invalid text field`);
continue;
}
// Check if value already exists
const existingKey = this._findKeyByValue(value);
if (existingKey) {
console.warn(`addOptions: Skipping item at index ${i} - value "${value}" already exists`);
continue;
}
// Extract extra data fields
const extraData = {};
if (extraDataFields && extraDataFields.length > 0) {
extraDataFields.forEach(fieldName => {
if (item.hasOwnProperty(fieldName)) {
extraData[fieldName] = item[fieldName];
}
});
}
// Create native option
const nativeOption = document.createElement('option');
nativeOption.value = value;
nativeOption.textContent = text;
nativeOption.selected = selected;
nativeOption.disabled = disabled;
// Create list item
const li = document.createElement('li');
const key = `option-${this._optionIdCounter++}`;
const index = this._nativeSelect.options.length + itemsToAdd.length;
li.className = `multiselect-option w-full ${disabled ? 'disabled opacity-50' : ''}`;
li.dataset.index = index;
li.dataset.value = value;
li.dataset.text = text.toLowerCase();
li.dataset.optionKey = key;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', selected ? 'true' : 'false');
if (disabled) {
li.setAttribute('aria-disabled', 'true');
}
// Create inner div
const div = document.createElement('div');
div.className = `flex items-center gap-3 cursor-pointer w-full ${this._getPaddingClass()} rounded ${disabled ? 'pointer-events-none' : ''}`;
// Create checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = `checkbox ${this._getCheckboxColorClass()} ${this._getCheckboxSizeClass()} pointer-events-none`;
checkbox.checked = selected;
checkbox.disabled = disabled;
// Prepare item data for renderer
const itemData = {
value: value,
text: text,
selected: selected,
disabled: disabled,
index: index,
...extraData
};
// Render content using custom renderer or default
const contentHtml = this._renderOptionContent(itemData);
// Assemble elements - create temporary container to parse HTML without destroying checkbox
div.appendChild(checkbox);
// Create temporary container to parse HTML
const tempContainer = document.createElement('div');
tempContainer.innerHTML = contentHtml;
// Move all parsed nodes from temp container to div (preserves checkbox)
while (tempContainer.firstChild) {
div.appendChild(tempContainer.firstChild);
}
li.appendChild(div);
// Add to fragments
fragment.appendChild(li);
nativeFragment.appendChild(nativeOption);
// Store for later processing (including checkbox reference)
itemsToAdd.push({
key,
value,
text,
selected,
disabled,
li,
nativeOption,
checkbox,
extraData
});
if (selected) {
hasSelectedItems = true;
}
}
// Batch DOM updates - single reflow
const ul = this.querySelector('.multiselect-options-list');
if (!ul) {
console.error('addOptions: options list not found');
return 0;
}
// Add all items at once
this._nativeSelect.appendChild(nativeFragment);
ul.appendChild(fragment);
// Update internal state
itemsToAdd.forEach(({ key, value, text, selected, disabled, li, nativeOption, checkbox, extraData }) => {
// Update value dictionary (including checkbox for caching)
this._valueDict[key] = {
value,
text,
selected,
disabled,
optionEl: li,
nativeOption,
checkbox, // Cache checkbox reference for performance
...extraData
};
// Handle selection state
if (selected) {
this._keysSelected[key] = true;
li.classList.add('selected');
if (this._highlight) {
li.classList.add('highlight');
}
if (this._color !== 'primary') {
li.classList.add(`color-${this._color}`);
}
}
});
// Update cached options list (options were already rendered with correct content)
this._updateOptionsCache();
this._updateDisplay();
// Note: Change event is dispatched by _performDisplayUpdate
return itemsToAdd.length;
} catch (error) {
this._handleError(error, 'addOptions');
return 0;
}
}
/**
* Remove an option by value
* @param {string} value - The value of the option to remove
* @returns {boolean} - True if option was found and removed
*/
removeOption(value) {
try {
const key = this._findKeyByValue(value);
if (!key) {
console.warn(`Option with value "${value}" not found`);
return false;
}
const item = this._valueDict[key];
// No need to remove individual listener - using event delegation on parent
// Remove from DOM
item.optionEl.remove();
item.nativeOption.remove();
// Remove from selected keys
if (this._keysSelected[key]) {
delete this._keysSelected[key];
this._selectedCount--; // Update count
this._selectedItemsDirty = true; // Mark cache dirty
}
// Remove from value dict
delete this._valueDict[key];
// Update cached options list after removal
this._updateOptionsCache();
// Update display
this._updateDisplay();
// Trigger lifecycle hook
this._trigger('onOptionRemoved', {
key,
value,
text: item.text
});
// Note: Change event is dispatched by _performDisplayUpdate
return true;
} catch (error) {
this._handleError(error, 'removeOption');
return false;
}
}
/**
* Select an option by value
* @param {string} value - The value of the option to select
* @returns {boolean} - True if option was found and selected
*/
selectByValue(value) {
const key = this._findKeyByValue(value);
if (!key) {
console.warn(`Option with value "${value}" not found`);
return false;
}
if (!this._keysSelected[key]) {
this._toggleSelection(key);
}
return true;
}
/**
* Deselect an option by value
* @param {string} value - The value of the option to deselect
* @returns {boolean} - True if option was found and deselected
*/
deselectByValue(value) {
const key = this._findKeyByValue(value);
if (!key) {
console.warn(`Option with value "${value}" not found`);
return false;
}
if (this._keysSelected[key]) {
this._toggleSelection(key);
}
return true;
}
/**
* Select an option by index
* @param {number} index - The zero-based index of the option to select
* @returns {boolean} - True if option was found and selected
*/
selectByIndex(index) {
// Find option by index in the current options list
const options = this.querySelectorAll('.multiselect-option');
if (index < 0 || index >= options.length) {
console.warn(`Option at index ${index} not found`);
return false;
}
const optionEl = options[index];
const key = optionEl.dataset.optionKey;
if (!this._valueDict[key]) {
console.warn(`Option at index ${index} not found in dictionary`);
return false;
}
if (this._valueDict[key].disabled) {
console.warn(`Option at index ${index} is disabled`);
return false;
}
if (!this._keysSelected[key]) {
this._toggleSelection(key);
}
return true;
}
/**
* Deselect an option by index
* @param {number} index - The zero-based index of the option to deselect
* @returns {boolean} - True if option was found and deselected
*/
deselectByIndex(index) {
// Find option by index in the current options list
const options = this.querySelectorAll('.multiselect-option');
if (index < 0 || index >= options.length) {
console.warn(`Option at index ${index} not found`);
return false;
}
const optionEl = options[index];
const key = optionEl.dataset.optionKey;
if (!this._valueDict[key]) {
console.warn(`Option at index ${index} not found in dictionary`);
return false;
}
if (this._keysSelected[key]) {
this._toggleSelection(key);
}
return true;
}
/**
* Check if an option exists
* @param {string} value - The value to check
* @returns {boolean} - True if option exists
*/
hasOption(value) {
return this._findKeyByValue(value) !== null;
}
/**
* Get all options
* @returns {Array} - Array of option objects
*/
getAllOptions() {
return Object.values(this._valueDict).map(item => ({
value: item.value,
text: item.text,
selected: this._keysSelected[this._findKeyByValue(item.value)] || false,
disabled: item.disabled
}));
}
/**
* Open the dropdown menu
* @public
* @example
* multiselect.open();
*/
open() {
try {
if (!this._isOpen && !this._disabled && this._dropdown && this._caret && this._triggerBtn) {
// Close all other open dropdowns
this._closeOtherDropdowns();
this._isOpen = true;
this._dropdown.classList.remove('hidden');
this._caret.style.transform = 'rotate(180deg)';
this._positionDropdown();
// Update ARIA
this._triggerBtn.setAttribute('aria-expanded', 'true');
// Dispatch open event
const openEvent = new CustomEvent('open', {
bubbles: true,
cancelable: false
});
this.dispatchEvent(openEvent);
}
} catch (error) {
this._handleError(error, 'open');
}
}
/**
* Close all other multiselect dropdowns
* @private
*/
_closeOtherDropdowns() {
const allSelects = document.querySelectorAll('daisy-multiselect');
allSelects.forEach(select => {
if (select !== this && select.isOpen()) {
select.close();
}
});
}
/**
* Close the dropdown menu
* @public
* @example
* multiselect.close();
*/
close() {
try {
if (this._isOpen && this._dropdown && this._caret && this._triggerBtn) {
this._isOpen = false;
this._dropdown.classList.add('hidden');
this._caret.style.transform = 'rotate(0deg)';
// Update ARIA
this._triggerBtn.setAttribute('aria-expanded', 'false');
// Dispatch close event
const closeEvent = new CustomEvent('close', {
bubbles: true,
cancelable: false
});
this.dispatchEvent(closeEvent);
}
} catch (error) {
this._handleError(error, 'close');
}
}
/**
* Toggle the dropdown open/closed state
*/
toggle() {
if (this._isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Check if dropdown is open
* @returns {boolean}
*/
isOpen() {
return this._isOpen;
}
/**
* Configure advanced options via JavaScript
* @param {Object} options - Configuration options
* @param {Function} [options.customRenderer] - Custom renderer function for dropdown options
* @param {string} [options.valueField='value'] - Field name for option values in addOptions
* @param {string} [options.textField='text'] - Field name for option text in addOptions
* @param {string} [options.selectedField='selected'] - Field name for selected state in addOptions
* @param {string} [options.disabledField='disabled'] - Field name for disabled state in addOptions
* @param {string[]} [options.extraDataFields=[]] - Array of field names to include as extra data in addOptions
* @public
* @example
* // Configure custom renderer
* multiselect.configure({
* customRenderer: (item) => {
* return `
* <img src="/icons/${item.value}.png" class="w-6 h-6">
* <div>
* <div class="font-bold">${item.text}</div>
* <div class="text-xs opacity-60">${item.description || ''}</div>
* </div>
* `;
* }
* });
*
* @example
* // Configure field mappings for addOptions
* multiselect.configure({
* valueField: 'id',
* textField: 'name',
* selectedField: 'isActive',
* disabledField: 'isDisabled',
* extraDataFields: ['email', 'role', 'avatar']
* });
*/
configure(options) {
try {
if (!options || typeof options !== 'object') {
console.error('configure: options must be an object');
return;
}
// Set custom renderer
if (options.customRenderer) {
if (typeof options.customRenderer !== 'function') {
console.error('configure: customRenderer must be a function');
return;
}
this._log('Setting custom renderer');
this._customRenderer = options.customRenderer;
// Re-render all options with the new custom renderer
this._reRenderAllOptions();
}
// Set field mappings for addOptions
if (options.valueField !== undefined) {
if (typeof options.valueField !== 'string') {
console.error('configure: valueField must be a string');
return;
}
this._valueField = options.valueField;
}
if (options.textField !== undefined) {
if (typeof options.textField !== 'string') {
console.error('configure: textField must be a string');
return;
}
this._textField = options.textField;
}
if (options.selectedField !== undefined) {
if (typeof options.selectedField !== 'string' && options.selectedField !== null) {
console.error('configure: selectedField must be a string or null');
return;
}
this._selectedField = options.selectedField;
}
if (options.disabledField !== undefined) {
if (typeof options.disabledField !== 'string' && options.disabledField !== null) {
console.error('configure: disabledField must be a string or null');
return;
}
this._disabledField = options.disabledField;
}
if (options.extraDataFields !== undefined) {
if (!Array.isArray(options.extraDataFields)) {
console.error('configure: extraDataFields must be an array');
return;
}
this._extraDataFields = options.extraDataFields;
}
} catch (error) {
this._handleError(error, 'configure');
}
}
/**
* Updates the custom renderer and re-renders all options (private).
* Use configure() for public API.
*/
_updateRenderer(renderer) {
if (renderer !== null && typeof renderer !== 'function') {
console.error('updateRenderer: renderer must be a function or null');
return;
}
if (this._customRenderer === renderer) {
this._log('Renderer unchanged, skipping update');
return;
}
this._log('Updating custom renderer');
this._customRenderer = renderer;
if (renderer) {
this._reRenderAllOptions();
} else {
this._updateOptionsCache();
}
}
/**
* Removes the custom renderer and reverts to default rendering (private).
*/
_removeRenderer() {
this._updateRenderer(null);
}
/**
* Gets the current custom renderer function (private).
*/
_getRenderer() {
return this._customRenderer || null;
}
/**
* Registers a callback for a lifecycle event.
* @param {string} event - Event name (e.g., 'onSetupComplete', 'onRenderComplete')
* @param {Function} callback - Callback function to execute
* @public
* @example
* element.on('onSetupComplete', (event) => {
* console.log('Setup complete!', event.optionCount);
* });
*/
on(event, callback) {
if (!this._hooks[event]) {
console.warn(`[MultiSelect] Unknown event: ${event}. Available: ${Object.keys(this._hooks).join(', ')}`);
return;
}
if (typeof callback !== 'function') {
console.error('[MultiSelect] Callback must be a function');
return;
}
this._hooks[event].push(callback);
this._log(`Registered callback for ${event}`);
}
/**
* Removes a callback for a lifecycle event.
* @param {string} event - Event name
* @param {Function} callback - Callback function to remove
* @public
* @example
* element.off('onSetupComplete', myCallback);
*/
off(event, callback) {
if (!this._hooks[event]) return;
const index = this._hooks[event].indexOf(callback);
if (index > -1) {
this._hooks[event].splice(index, 1);
this._log(`Removed callback for ${event}`);
}
}
/**
* Triggers all callbacks for an event.
* @private
* @param {string} event - Event name
* @param {Object} data - Data to pass to callbacks
*/
_trigger(event, data = {}) {
if (!this._hooks[event]) return;
this._log(`Triggering ${event} with ${this._hooks[event].length} callbacks`);
this._hooks[event].forEach(callback => {
try {
callback({ component: this, ...data });
} catch (error) {
console.error(`[MultiSelect] Error in ${event} callback:`, error);
}
});
}
/**
* Get the enabled state
* @returns {boolean} true if enabled, false if disabled
*/
get enabled() {
return !this._disabled;
}
/**
* Set the enabled state
* @param {boolean} value - true to enable, false to disable
*/
set enabled(value) {
const shouldEnable = !!value;
if (shouldEnable) {
// Enable the component
this._disabled = false;
this.removeAttribute('disabled');
this._triggerBtn.disabled = false;
this._applyUserAttributes(); // Reapply to update classes
// Show clear button if it exists
const clearBtn = this.querySelector('.multiselect-clear-btn');
if (clearBtn && this._showClear) {
clearBtn.classList.remove('hidden');
}
} else {
// Disable the component
this._disabled = true;
this.setAttribute('disabled', '');
this._triggerBtn.disabled = true;
this._applyUserAttributes(); // Reapply to update classes
// Close dropdown if open
if (this._isOpen) {
this.close();
}
// Hide clear button
const clearBtn = this.querySelector('.multiselect-clear-btn');
if (clearBtn) {
clearBtn.classList.add('hidden');
}
}
}
/**
* Get the visible state
* @returns {boolean} true if visible, false if hidden
*/
get visible() {
return this.style.display !== 'none';
}
/**
* Set the visible state
* @param {boolean} value - true to show, false to hide
*/
set visible(value) {
const shouldShow = !!value;
if (shouldShow) {
// Show the component
this.style.display = '';
} else {
// Hide the component
this.style.display = 'none';
// Close dropdown if open
if (this._isOpen) {
this.close();
}
}
}
/**
* Check if no selections are made
* @returns {boolean} true if nothing is selected, false if there are selections
*/
isBlank() {
return this._selectedCount === 0;
}
/**
* Get the required state
* @returns {boolean}
*/
get required() {
return this._required;
}
/**
* Set the required state
* @param {boolean} value
*/
set required(value) {
this._required = !!value;
if (this._required) {
this.setAttribute('required', '');
} else {
this.removeAttribute('required');
}
// Update border color when required state changes
this._updateBorderColor();
}
/**
* Get the current value (selected values as array)
* @returns {Array<string>} Array of selected values
*/
get value() {
return this.getSelectedValues();
}
/**
* Set the value (select items by values)
* @param {Array<string>|string} value - Array of values or delimiter-separated string
*/
set value(value) {
// Clear all current selections
this.clearSelected();
// Parse input
let values = [];
if (Array.isArray(value)) {
values = value;
} else if (typeof value === 'string') {
values = value.split(this._delimiter).map(v => v.trim()).filter(v => v);
}
// Select each value
values.forEach(val => {
this.selectByValue(val);
});
}
/**
* Get the current delimiter
* @returns {string} The delimiter character(s) used for value strings
*/
get delimiter() {
return this._delimiter;
}
/**
* Set the delimiter for value strings
* @param {string} value - The delimiter character(s) to use (default: ';')
*/
set delimiter(value) {
if (typeof value === 'string' && value.length > 0) {
this._delimiter = value;
this.setAttribute('delimiter', value);
} else {
console.warn('delimiter must be a non-empty string');
}
}
/**
* Get or set the checked color (for the checkboxes)
*/
get checkedColor() {
return this._color;
}
set checkedColor(value) {
if (typeof value === 'string' && value.length > 0) {
this._color = value;
this.setAttribute('checked-color', value);
this._applyCheckedColorToOptions();
this._applyUserAttributes();
} else {
console.warn('checkedColor must be a non-empty string');
}
}
/**
* Get the current border color
* @returns {string|null} The border color (named color, hex value, or custom value) or null if no border color is set
*/
get borderColor() {
if (!this._triggerBtn) return null;
const borderColors = ['error', 'success', 'primary', 'secondary', 'accent', 'info', 'warning', 'neutral'];
// Check for named color classes
for (const color of borderColors) {
if (this._triggerBtn.classList.contains(`border-${color}`)) {
return color;
}
}
// Check for custom border color (hex or other)
const customColor = this._triggerBtn.style.borderColor;
return customColor || null;
}
/**
* Set the border color
* @param {string|null} color - The border color to apply:
* - Named colors: 'error', 'success', 'primary', 'secondary', 'accent', 'info', 'warning', 'neutral'
* - Hex colors: '#ff0000', '#00ff00', etc.
* - null to remove all border colors
*/
set borderColor(color) {
if (!this._triggerBtn) return;
const borderColors = ['error', 'success', 'primary', 'secondary', 'accent', 'info', 'warning', 'neutral'];
// Remove all existing border color classes
borderColors.forEach(c => {
this._triggerBtn.classList.remove(`border-${c}`);
});
// Clear inline border color
this._triggerBtn.style.borderColor = '';
if (!color) {
return; // No color to apply
}
// Check if it's a named DaisyUI color
if (borderColors.includes(color)) {
this._triggerBtn.classList.add(`border-${color}`);
}
// Check if it's a hex color (3 or 6 digit)
else if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)) {
this._triggerBtn.style.borderColor = color;
}
// Otherwise, try to apply it as a custom color value
else {
this._triggerBtn.style.borderColor = color;
}
}
/**
* Get chip-style mode
* @returns {boolean} true if chip-style mode is enabled
*/
get chipStyle() {
return this._chipStyle;
}
/**
* Set chip-style mode
* @param {boolean} value - true to enable chip-style, false for comma-separated
*/
set chipStyle(value) {
const shouldEnable = !!value;
if (shouldEnable !== this._chipStyle) {
this._chipStyle = shouldEnable;
if (this._chipStyle) {
this.setAttribute('chip-style', '');
} else {
this.removeAttribute('chip-style');
}
// Re-render display with new mode
this._updateDisplay();
}
}
/**
* Helper method to find key by value
* @private
*/
_findKeyByValue(value) {
for (let key in this._valueDict) {
if (this._valueDict[key].value === value) {
return key;
}
}
return null;
}
/**
* Updates the cached reference to option elements.
* Lightweight operation - just queries DOM.
* @private
*/
_updateOptionsCache() {
this._options = this.querySelectorAll('.multiselect-option');
this._log(`Options cache updated: ${this._options.length} options`);
}
/**
* Re-renders all options with the current custom renderer.
* Expensive operation - manipulates DOM for each option.
* @private
*/
_reRenderAllOptions() {
this._log('Re-rendering all options with custom renderer...');
this._updateOptionsCache();
// Trigger lifecycle hook at start
this._trigger('onRenderStart', {
optionCount: this._options.length
});
if (!this._customRenderer) {
this._log('No custom renderer set, skipping re-render');
return;
}
let successCount = 0;
let errorCount = 0;
this._options.forEach((option, index) => {
try {
const key = option.dataset.optionKey;
if (!key || !this._valueDict[key]) {
this._log(`Option ${index} missing key or data, skipping`);
return;
}
const itemData = this._valueDict[key];
const div = option.querySelector('div.flex.items-center');
if (!div) {
console.error(`[MultiSelect] Option ${index} (${key}) missing container div`);
errorCount++;
return;
}
const checkbox = div.querySelector('input[type="checkbox"]');
if (!checkbox) {
console.error(`[MultiSelect] Option ${index} (${key}) missing checkbox`);
errorCount++;
return;
}
// Find all nodes that are NOT the checkbox
const nodesToRemove = [];
div.childNodes.forEach(node => {
if (node !== checkbox) {
nodesToRemove.push(node);
}
});
// Remove all non-checkbox nodes
nodesToRemove.forEach(node => {
try {
div.removeChild(node);
} catch (error) {
console.warn(`[MultiSelect] Error removing node from option ${key}:`, error);
}
});
// Render new content with custom renderer
let contentHtml;
try {
contentHtml = this._renderOptionContent(itemData);
if (typeof contentHtml !== 'string') {
throw new Error(`Custom renderer must return a string, got ${typeof contentHtml}`);
}
if (!contentHtml.trim()) {
throw new Error('Custom renderer returned empty string');
}
} catch (renderError) {
console.error(`[MultiSelect] Custom renderer error for option "${key}":`, renderError);
// Fall back to default rendering
contentHtml = `<span class="${this._getTextSizeClass()}">${this._escapeHtml(itemData.text)}</span>`;
// Mark option as having error
option.classList.add('multiselect-render-error');
option.title = `Rendering error: ${renderError.message}`;
errorCount++;
}
// Safely insert new content
try {
const tempContainer = document.createElement('div');
tempContainer.innerHTML = contentHtml;
if (!tempContainer.firstChild) {
throw new Error('Generated HTML produced no DOM nodes');
}
// Append new content after checkbox
while (tempContainer.firstChild) {
div.appendChild(tempContainer.firstChild);
}
successCount++;
} catch (insertError) {
console.error(`[MultiSelect] Error inserting content for option "${key}":`, insertError);
// Last resort: insert plain text
const span = document.createElement('span');
span.className = this._getTextSizeClass();
span.textContent = itemData.text;
div.appendChild(span);
errorCount++;
}
} catch (error) {
console.error(`[MultiSelect] Unexpected error processing option ${index}:`, error);
errorCount++;
}
});
this._log(`Re-render complete: ${successCount} succeeded, ${errorCount} failed`);
if (errorCount > 0) {
console.warn(`[MultiSelect] ${errorCount} options failed to render correctly`);
}
this._trigger('onRenderComplete', {
optionCount: this._options.length,
successCount,
errorCount
});
}
/**
* Legacy method for backward compatibility.
* Calls _reRenderAllOptions() if custom renderer exists, otherwise just updates cache.
* @deprecated Use _updateOptionsCache() or _reRenderAllOptions() directly for clarity.
* @private
*/
_refreshOptions() {
if (this._customRenderer) {
this._reRenderAllOptions();
} else {
this._updateOptionsCache();
}
}
_filterOptions(searchTerm) {
const lowerSearch = searchTerm.toLowerCase().trim();
const optionsList = this.querySelector('.multiselect-options-list');
const noResults = this.querySelector('.multiselect-no-results');
let visibleCount = 0;
// If filtering, temporarily disable virtual scrolling to show all matching items
const wasVirtualScrollActive = this._virtualScrollEnabled;
if (lowerSearch !== '') {
this._disableVirtualScroll();
}
this._options.forEach(option => {
const text = option.dataset.text;
if (text.includes(lowerSearch)) {
option.style.display = '';
visibleCount++;
} else {
option.style.display = 'none';
}
});
// If search is cleared and virtual scroll was active, re-enable it
if (lowerSearch === '' && wasVirtualScrollActive) {
this._enableVirtualScroll();
}
// Show/hide no results message
if (visibleCount === 0) {
noResults.classList.remove('hidden');
} else {
noResults.classList.add('hidden');
}
}
_navigateOptions(direction) {
const visibleOptions = Array.from(this._options).filter(opt => opt.style.display !== 'none' && !opt.classList.contains('disabled'));
if (visibleOptions.length === 0) return;
let currentIndex = visibleOptions.findIndex(opt => opt.classList.contains('focused'));
// Remove current focus
if (currentIndex >= 0) {
visibleOptions[currentIndex].classList.remove('focused');
}
// Calculate new index
if (currentIndex === -1) {
currentIndex = direction > 0 ? 0 : visibleOptions.length - 1;
} else {
currentIndex = (currentIndex + direction + visibleOptions.length) % visibleOptions.length;
}
// Add focus to new option
const newOption = visibleOptions[currentIndex];
newOption.classList.add('focused');
newOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
_selectFocusedOption() {
const focusedOption = Array.from(this._options).find(opt => opt.classList.contains('focused'));
if (focusedOption) {
const key = focusedOption.dataset.optionKey;
this._toggleSelection(key);
// Note: Change event is dispatched by _performDisplayUpdate
}
}
_scheduleMaxSelectionsUpdate() {
// Debounce max selections state updates
if (this._maxSelectionsUpdatePending) return;
this._maxSelectionsUpdatePending = true;
requestAnimationFrame(() => {
this._maxSelectionsUpdatePending = false;
this._updateMaxSelectionsState();
});
}
_updateMaxSelectionsState() {
if (this._maxSelections <= 0) return;
const limitReached = this._selectedCount >= this._maxSelections;
// Batch class operations for better performance
const fragment = document.createDocumentFragment();
this._options.forEach(option => {
const key = option.dataset.optionKey;
const isSelected = this._keysSelected[key];
const item = this._valueDict[key];
if (item && !item.disabled && !isSelected) {
const classList = option.classList;
if (limitReached) {
classList.add('opacity-50', 'pointer-events-none');
} else {
classList.remove('opacity-50', 'pointer-events-none');
}
}
});
}
/**
* Destroy the component and clean up all resources
* Call this method before removing the element from the DOM
* @public
*/
destroy() {
// Remove all event handlers via AbortController
this._removeEventHandlers();
// Clear all data structures
this._keysSelected = null;
this._valueDict = null;
this._allOptions = null;
this._checkboxCache = null;
// Remove hex color styles if they exist
if (this._hexColorStyleElement && this._hexColorStyleElement.parentNode) {
this._hexColorStyleElement.remove();
this._hexColorStyleElement = null;
}
// Clear all DOM references
this._triggerBtn = null;
this._dropdown = null;
this._display = null;
this._caret = null;
this._nativeSelect = null;
this._options = null;
this._statusElement = null;
// Clear bound methods
this._toggleBound = null;
this._optionClickBound = null;
this._keydownBound = null;
this._virtualScrollBound = null;
// Clear cached data
this._removeSvgCache = null;
this._updateDisplayPending = false;
// Clear AbortController
this._abortController = null;
// Clear component HTML
this.innerHTML = '';
// Mark as destroyed for debugging
this._destroyed = true;
}
}
// Register the custom element
customElements.define('daisy-multiselect', DaisyMultiSelect);