3091 lines
109 KiB
JavaScript
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);
|
|
|
|
|