UNPKG

@gpaol/bootstrap-multiselect

Version:

Bootstrap 5 MultiSelect component with search, filtering, grouped options, pagination, and comprehensive localization support

1,464 lines • 58.6 kB
/*!
 * Bootstrap MultiSelect - jQuery Plugin v1.2.0
 * 
 * A powerful multi-select dropdown plugin built with Bootstrap 5
 * Supports declarative configuration via data attributes and programmatic control via JavaScript API
 * 
 * This plugin transforms standard HTML select elements or input fields into feature-rich
 * multi-select dropdowns with search, filtering, select all/deselect all functionality,
 * maximum selection limits, disabled options, optgroup support, pagination, and more.
 * 
 * Authors: Paolo Gaetano
 * Copyright (c) 2026 Paolo Gaetano
 * Released under the MIT license
 * 
 * @requires jQuery >= 3.6
 * @requires Bootstrap >= 5.0
 */

(function ($) {
    /**
     * Bootstrap MultiSelect - jQuery Plugin
     * 
     * A powerful multi-select dropdown plugin built with Bootstrap 5
     * Supports declarative configuration via data attributes and programmatic control via JavaScript API
     * 
     * @requires jQuery >= 3.6
     * @requires Bootstrap >= 5.0
     * 
     * @description
     * This plugin transforms standard HTML select elements or input fields into feature-rich
     * multi-select dropdowns with search, filtering, select all/deselect all functionality,
     * maximum selection limits, disabled options, optgroup support, and more.
     * 
     * @example
     * // Basic usage with a select element
     * <select id="mySelect" data-toggle="bootstrap-multiselect">
     *     <option value="1">Option 1</option>
     *     <option value="2">Option 2</option>
     *     <option value="3">Option 3</option>
     * </select>
     * 
     * @example
     * // Declarative configuration via data attributes
     * <select data-toggle="bootstrap-multiselect"
     *         data-placeholder="Select items..."
     *         data-search="true"
     *         data-select-all="true"
     *         data-max-selection="3"
     *         data-theme="primary">
     *     <option value="1">Option 1</option>
     *     <option value="2" selected>Option 2</option>
     *     <option value="3" disabled>Option 3 (Disabled)</option>
     * </select>
     * 
     * @example
     * // Programmatic initialization with JavaScript
     * $('#mySelect').bootstrapMultiSelect({
     *     placeholder: 'Choose options...',
     *     search: true,
     *     selectAll: true,
     *     maxSelection: 5,
     *     theme: 'success',
     *     width: '300px',
     *     data: [
     *         { id: '1', text: 'Option 1' },
     *         { id: '2', text: 'Option 2', selected: true },
     *         { id: '3', text: 'Option 3', disabled: true }
     *     ]
     * });
     * 
     * @example
     * // Using OptGroups
     * <select data-toggle="bootstrap-multiselect">
     *     <optgroup label="Fruits">
     *         <option value="apple">Apple</option>
     *         <option value="banana">Banana</option>
     *     </optgroup>
     *     <optgroup label="Vegetables">
     *         <option value="carrot">Carrot</option>
     *         <option value="broccoli">Broccoli</option>
     *     </optgroup>
     * </select>
     * 
     * @example
     * // JavaScript API - Get selected values
     * var selected = $('#mySelect').bootstrapMultiSelect('getSelectedValues');
     * console.log(selected); // ['1', '2', '3']
     * 
     * @example
     * // JavaScript API - Set selected values
     * $('#mySelect').bootstrapMultiSelect('setSelectedValues', ['1', '3', '5']);
     * 
     * @example
     * // JavaScript API - Refresh the dropdown
     * $('#mySelect').bootstrapMultiSelect('refresh');
     * 
     * @example
     * // JavaScript API - Reset to empty state
     * $('#mySelect').bootstrapMultiSelect('reset');
     * 
     * @example
     * // JavaScript API - Enable/Disable the control
     * $('#mySelect').bootstrapMultiSelect('disable');
     * $('#mySelect').bootstrapMultiSelect('enable');
     * 
     * @example
     * // JavaScript API - Destroy the plugin
     * $('#mySelect').bootstrapMultiSelect('destroy');
     * 
     * @example
     * // Listen to change events
     * $('#mySelect').on('change.bs-multiselect', function(e, selectedValues) {
     *     console.log('Selected values:', selectedValues);
     * });
     * 
     * @example
     * // Advanced configuration with all options
     * $('#mySelect').bootstrapMultiSelect({
     *     placeholder: 'Select items...',
     *     maxSelection: 3,              // Max number of items that can be selected
     *     selectAll: true,              // Show "Select All" button
     *     search: true,                 // Enable search functionality
     *     width: '100%',                // Width of the dropdown
     *     theme: 'primary',             // Bootstrap theme (primary, success, danger, etc.)
     *     closeOnSelect: false,         // Keep dropdown open after selecting an item
     *     buttonClass: 'btn-outline-primary',  // Custom button class
     *     dropdownClass: 'custom-dropdown',    // Custom dropdown class
     *     searchPlaceholder: 'Search...',      // Search input placeholder
     *     selectAllText: 'Select All',         // Text for select all button
     *     deselectAllText: 'Deselect All',     // Text for deselect all button
     *     noResultsText: 'No results found',   // Text when search has no results
     *     maxHeight: '300px',                  // Max height of dropdown
     *     valueField: 'id',                    // Field name for value
     *     textField: 'text'                    // Field name for display text
     * });
     * 
     * @fires change.bs-multiselect - Triggered when selection changes
     * 
     * @see https://getbootstrap.com/docs/5.3/getting-started/introduction/
     * @see https://jquery.com/
     */
    'use strict';

    // Ensure the plugin namespace exists
    $.fn.bootstrapMultiSelect = $.fn.bootstrapMultiSelect || {};

    /**
     * Global localization storage
     * Can be overridden by loading a localization file
     */
    $.fn.bootstrapMultiSelect.langs = $.fn.bootstrapMultiSelect.langs || {
        'en': {
            placeholder: 'Select items...',
            searchPlaceholder: 'Search...',
            selectAllText: 'Select All',
            deselectAllText: 'Deselect All',
            noResultsText: 'No results found',
            itemsSelectedText: 'items selected',
            paginationPrevText: 'Previous',
            paginationNextText: 'Next',
            paginationInfoText: 'Page {current} of {total}'
        }
    };

    /**
     * Default language (can be changed globally)
     */
    $.fn.bootstrapMultiSelect.lang = $.fn.bootstrapMultiSelect.lang || 'en';

    /**
     * Default plugin options
     */
    const defaults = {
        placeholder: 'Select items...',
        maxSelection: 0,
        selectAll: true,
        search: true,
        width: '100%',
        theme: 'primary',
        data: [],
        valueField: 'id',
        textField: 'text',
        selectedValues: [],
        closeOnSelect: false,
        enableClickableOptGroups: false,
        buttonClass: 'btn-outline-secondary',
        dropdownClass: '',
        searchPlaceholder: 'Search...',
        selectAllText: 'Select All',
        deselectAllText: 'Deselect All',
        noResultsText: 'No results found',
        maxHeight: '300px',
        itemsSelectedText: 'items selected',
        enablePagination: false,
        itemsPerPage: 10,
        paginationPrevText: 'Previous',
        paginationNextText: 'Next',
        paginationInfoText: 'Page {current} of {total}',
        paginationPosition: 'bottom', // 'top', 'bottom', or 'both'
        lang: null // If null, uses global lang
    };

    /**
     * BootstrapMultiSelect class
     */
    class BootstrapMultiSelect {
        /**
         * Constructor
         * 
         * @param {HTMLElement} element - The input/select element
         * @param {Object} options - Plugin options
         */
        constructor(element, options) {
            this.$element = $(element);
            this.options = this._getOptions(options);
            this.id = this._generateId();
            this.isOpen = false;
            this.selectedItems = [];
            this.currentPage = 1;
            this.filteredData = [];

            this._init();
        }

        /**
         * Get merged options from defaults, data attributes, and passed options
         * 
         * @param {Object} options - User provided options
         * @returns {Object} Merged options
         */
        _getOptions(options) {
            const dataOptions = {
                placeholder: this.$element.data('placeholder'),
                maxSelection: this.$element.data('max-selection'),
                selectAll: this.$element.data('select-all'),
                search: this.$element.data('search'),
                width: this.$element.data('width'),
                theme: this.$element.data('theme'),
                valueField: this.$element.data('value-field'),
                textField: this.$element.data('text-field'),
                closeOnSelect: this.$element.data('close-on-select'),
                buttonClass: this.$element.data('button-class'),
                dropdownClass: this.$element.data('dropdown-class'),
                searchPlaceholder: this.$element.data('search-placeholder'),
                selectAllText: this.$element.data('select-all-text'),
                deselectAllText: this.$element.data('deselect-all-text'),
                noResultsText: this.$element.data('no-results-text'),
                maxHeight: this.$element.data('max-height'),
                itemsSelectedText: this.$element.data('items-selected-text'),
                enablePagination: this.$element.data('enable-pagination'),
                itemsPerPage: this.$element.data('items-per-page'),
                paginationPrevText: this.$element.data('pagination-prev-text'),
                paginationNextText: this.$element.data('pagination-next-text'),
                paginationInfoText: this.$element.data('pagination-info-text'),
                paginationPosition: this.$element.data('pagination-position'),
                lang: this.$element.data('lang')
            };

            // Remove undefined values
            Object.keys(dataOptions).forEach(key => {
                if (dataOptions[key] === undefined) {
                    delete dataOptions[key];
                }
            });

            // Merge options: defaults -> data attributes -> passed options
            const mergedOptions = $.extend(true, {}, defaults, dataOptions, options);

            // Apply global localization if lang is specified and no text overrides are provided
            if ($.fn.bootstrapMultiSelect && $.fn.bootstrapMultiSelect.langs) {
                // Use explicit lang from user (data-lang or options.lang), otherwise use global lang
                let lang;
                if (dataOptions.lang !== undefined) {
                    // User specified data-lang attribute
                    lang = dataOptions.lang;
                } else if (options && options.lang !== undefined) {
                    // User specified lang in JavaScript options
                    lang = options.lang;
                } else {
                    // No user specification, use global lang
                    lang = $.fn.bootstrapMultiSelect.lang || 'en';
                }

                const langData = $.fn.bootstrapMultiSelect.langs[lang];

                if (langData) {
                    // Only apply lang values if they weren't explicitly set by user
                    if (!dataOptions.placeholder && !options?.placeholder) {
                        mergedOptions.placeholder = langData.placeholder;
                    }
                    if (!dataOptions.searchPlaceholder && !options?.searchPlaceholder) {
                        mergedOptions.searchPlaceholder = langData.searchPlaceholder;
                    }
                    if (!dataOptions.selectAllText && !options?.selectAllText) {
                        mergedOptions.selectAllText = langData.selectAllText;
                    }
                    if (!dataOptions.deselectAllText && !options?.deselectAllText) {
                        mergedOptions.deselectAllText = langData.deselectAllText;
                    }
                    if (!dataOptions.noResultsText && !options?.noResultsText) {
                        mergedOptions.noResultsText = langData.noResultsText;
                    }
                    if (!dataOptions.itemsSelectedText && !options?.itemsSelectedText) {
                        mergedOptions.itemsSelectedText = langData.itemsSelectedText;
                    }
                    if (!dataOptions.paginationPrevText && !options?.paginationPrevText) {
                        mergedOptions.paginationPrevText = langData.paginationPrevText;
                    }
                    if (!dataOptions.paginationNextText && !options?.paginationNextText) {
                        mergedOptions.paginationNextText = langData.paginationNextText;
                    }
                    if (!dataOptions.paginationInfoText && !options?.paginationInfoText) {
                        mergedOptions.paginationInfoText = langData.paginationInfoText;
                    }
                }
            }

            return mergedOptions;
        }

        /**
         * Generate unique ID for the component
         * 
         * @returns {string} Unique ID
         */
        _generateId() {
            return 'bs-multiselect-' + Math.random().toString(36).substr(2, 9);
        }

        /**
         * Initialize the plugin
         */
        _init() {
            // Hide original element but keep it in the DOM for validation
            this.$element.css({
                'position': 'absolute',
                'left': '-9999px',
                'width': '1px',
                'height': '1px'
            });

            // Get data from select element or options
            this._loadData();

            // Build the dropdown structure
            this._buildDropdown();

            // Set initial selection BEFORE binding events to avoid triggering change handlers
            this._setInitialSelection();

            // Bind events
            this._bindEvents();
        }

        /**
         * Load data from select element or options
         */
        _loadData() {
            // If data is provided in options, use it
            if (this.options.data && Array.isArray(this.options.data) && this.options.data.length > 0) {
                // Data is already set in options, no need to parse from element
                return;
            }

            // Otherwise, try to parse from select element
            if (this.$element.is('select')) {
                this.options.data = this._parseSelectOptions();
            }
        }

        /**
         * Parse options from select element
         * 
         * @returns {Array} Array of option objects
         */
        _parseSelectOptions() {
            const data = [];
            const self = this;

            this.$element.find('option, optgroup').each(function () {
                const $this = $(this);

                if ($this.is('optgroup')) {
                    const group = {
                        label: $this.attr('label'),
                        children: []
                    };

                    $this.find('option').each(function () {
                        const $option = $(this);
                        group.children.push({
                            [self.options.valueField]: $option.val(),
                            [self.options.textField]: $option.text(),
                            selected: $option.prop('selected'),
                            disabled: $option.prop('disabled')
                        });
                    });

                    data.push(group);
                } else if (!$this.parent().is('optgroup')) {
                    data.push({
                        [self.options.valueField]: $this.val(),
                        [self.options.textField]: $this.text(),
                        selected: $this.prop('selected'),
                        disabled: $this.prop('disabled')
                    });
                }
            });

            return data;
        }

        /**
         * Build the dropdown HTML structure
         */
        _buildDropdown() {
            const buttonClass = this.options.buttonClass || `btn-outline-${this.options.theme}`;

            this.$container = $(`
                <div class="bs-multiselect-container dropdown" style="width: ${this.options.width}">
                    <button class="btn ${buttonClass} dropdown-toggle w-100 text-start d-flex justify-content-between align-items-center" 
                            type="button" 
                            id="${this.id}-button" 
                            data-bs-toggle="dropdown" 
                            aria-expanded="false">
                        <span class="bs-multiselect-placeholder">${this.options.placeholder}</span>
                        <span class="bs-multiselect-badge badge bg-${this.options.theme} ms-2" style="display: none;">0</span>
                    </button>
                    <div class="dropdown-menu w-100 ${this.options.dropdownClass}" 
                         aria-labelledby="${this.id}-button" 
                         style="max-height: ${this.options.maxHeight}; overflow-y: auto;">
                        ${this._buildDropdownContent()}
                    </div>
                </div>
            `);

            this.$element.after(this.$container);

            // Store references
            this.$button = this.$container.find('.dropdown-toggle');
            this.$dropdownMenu = this.$container.find('.dropdown-menu');
            this.$placeholder = this.$container.find('.bs-multiselect-placeholder');
            this.$badge = this.$container.find('.bs-multiselect-badge');
            this.$searchInput = this.$container.find('.bs-multiselect-search');
            this.$selectAllBtn = this.$container.find('.bs-multiselect-select-all');
            this.$deselectAllBtn = this.$container.find('.bs-multiselect-deselect-all');
            this.$optionsList = this.$container.find('.bs-multiselect-options');
            this.$noResults = this.$container.find('.bs-multiselect-no-results');
        }

        /**
         * Build dropdown content HTML
         * 
         * @returns {string} HTML content
         */
        _buildDropdownContent() {
            let html = '<div class="p-2">';

            // Search box
            if (this.options.search) {
                html += `
                    <div class="mb-2">
                        <input type="text" 
                               class="form-control form-control-sm bs-multiselect-search" 
                               placeholder="${this.options.searchPlaceholder}">
                    </div>
                `;
            }

            // Select All / Deselect All buttons
            if (this.options.selectAll) {
                html += `
                    <div class="mb-2 d-flex gap-2">
                        <button type="button" class="btn btn-sm btn-outline-primary bs-multiselect-select-all flex-fill">
                            ${this.options.selectAllText}
                        </button>
                        <button type="button" class="btn btn-sm btn-outline-secondary bs-multiselect-deselect-all flex-fill">
                            ${this.options.deselectAllText}
                        </button>
                    </div>
                `;
            }

            html += '<hr class="my-2">';

            // Pagination controls (top)
            if (this.options.enablePagination && (this.options.paginationPosition === 'top' || this.options.paginationPosition === 'both')) {
                html += this._buildPaginationControls();
            }

            // Options list
            html += '<div class="bs-multiselect-options">';
            html += this._buildOptions();
            html += '</div>';

            // Pagination controls (bottom)
            if (this.options.enablePagination && (this.options.paginationPosition === 'bottom' || this.options.paginationPosition === 'both')) {
                html += this._buildPaginationControls();
            }

            // No results message
            html += `
                <div class="bs-multiselect-no-results text-muted text-center py-2" style="display: none;">
                    <small>${this.options.noResultsText}</small>
                </div>
            `;

            html += '</div>';

            return html;
        }

        /**
         * Build pagination controls HTML
         * 
         * @returns {string} Pagination controls HTML
         */
        _buildPaginationControls() {
            const totalPages = this._getTotalPages();
            const paginationInfo = this.options.paginationInfoText
                .replace('{current}', this.currentPage)
                .replace('{total}', totalPages);

            return `
                <div class="bs-multiselect-pagination d-flex justify-content-between align-items-center mb-2">
                    <button type="button" class="btn btn-sm btn-outline-secondary bs-multiselect-prev-page" ${this.currentPage === 1 ? 'disabled' : ''}>
                        ${this.options.paginationPrevText}
                    </button>
                    <span class="bs-multiselect-page-info text-muted small">
                        ${paginationInfo}
                    </span>
                    <button type="button" class="btn btn-sm btn-outline-secondary bs-multiselect-next-page" ${this.currentPage === totalPages ? 'disabled' : ''}>
                        ${this.options.paginationNextText}
                    </button>
                </div>
            `;
        }

        /**
         * Get total number of pages
         * 
         * @returns {number} Total pages
         */
        _getTotalPages() {
            const dataToUse = this.filteredData.length > 0 ? this.filteredData : this.options.data;
            const flatItems = this._getFlatItems(dataToUse);
            return Math.max(1, Math.ceil(flatItems.length / this.options.itemsPerPage));
        }

        /**
         * Get flat list of items (excluding optgroups)
         * 
         * @param {Array} data - Data array
         * @returns {Array} Flat items array
         */
        _getFlatItems(data) {
            const items = [];
            data.forEach(item => {
                if (item.label && item.children) {
                    items.push(...item.children);
                } else {
                    items.push(item);
                }
            });
            return items;
        }

        /**
         * Get paginated items for current page
         * 
         * @returns {Array} Paginated items
         */
        _getPaginatedItems() {
            if (!this.options.enablePagination) {
                return this.options.data;
            }

            const dataToUse = this.filteredData.length > 0 ? this.filteredData : this.options.data;
            const flatItems = this._getFlatItems(dataToUse);

            const startIndex = (this.currentPage - 1) * this.options.itemsPerPage;
            const endIndex = startIndex + this.options.itemsPerPage;

            return flatItems.slice(startIndex, endIndex);
        }

        /**
         * Build options HTML
         * 
         * @returns {string} Options HTML
         */
        _buildOptions() {
            let html = '';

            const itemsToRender = this.options.enablePagination ? this._getPaginatedItems() : this.options.data;

            itemsToRender.forEach((item, index) => {
                if (item.label && item.children) {
                    // OptGroup
                    html += this._buildOptGroup(item, index);
                } else {
                    // Regular option
                    html += this._buildOption(item, index);
                }
            });

            return html;
        }

        /**
         * Build option group HTML
         * 
         * @param {Object} group - Group object
         * @param {number} index - Group index
         * @returns {string} OptGroup HTML
         */
        _buildOptGroup(group, index) {
            let html = `
                <div class="bs-multiselect-optgroup" data-group-index="${index}">
                    <div class="fw-bold text-muted small px-2 py-1">${group.label}</div>
                    <div class="ps-3">
            `;

            group.children.forEach((child, childIndex) => {
                html += this._buildOption(child, `${index}-${childIndex}`, true);
            });

            html += '</div></div>';

            return html;
        }

        /**
         * Build single option HTML
         * 
         * @param {Object} option - Option object
         * @param {string|number} index - Option index
         * @param {boolean} isChild - Is child of optgroup
         * @returns {string} Option HTML
         */
        _buildOption(option, index, isChild = false) {
            const value = option[this.options.valueField];
            const text = option[this.options.textField];
            const disabled = option.disabled ? 'disabled' : '';
            const disabledData = option.disabled ? 'data-original-disabled="true"' : '';
            // Don't set checked attribute here - let _setInitialSelection handle it
            // to avoid double-triggering events

            return `
                <div class="form-check bs-multiselect-option" data-value="${value}" data-index="${index}">
                    <input class="form-check-input" 
                           type="checkbox" 
                           value="${value}" 
                           id="${this.id}-option-${index}" 
                           ${disabled}
                           ${disabledData}>
                    <label class="form-check-label w-100" for="${this.id}-option-${index}">
                        ${text}
                    </label>
                </div>
            `;
        }

        /**
         * Bind event handlers
         */
        _bindEvents() {
            const self = this;

            // Prevent dropdown from closing on inside click
            this.$dropdownMenu.on('click', function (e) {
                if (!self.options.closeOnSelect) {
                    e.stopPropagation();
                }
            });

            // Checkbox change event
            this.$dropdownMenu.on('change', '.form-check-input', function (e) {
                e.stopPropagation();
                self._onOptionChange($(this));
            });

            // Select All button
            this.$selectAllBtn.on('click', function (e) {
                e.preventDefault();
                e.stopPropagation();
                self._selectAll();
            });

            // Deselect All button
            this.$deselectAllBtn.on('click', function (e) {
                e.preventDefault();
                e.stopPropagation();
                self._deselectAll();
            });

            // Search input
            if (this.options.search) {
                this.$searchInput.on('input', function () {
                    self._filterOptions($(this).val());
                });
            }

            // Pagination buttons
            if (this.options.enablePagination) {
                this.$dropdownMenu.on('click', '.bs-multiselect-prev-page', function (e) {
                    e.preventDefault();
                    e.stopPropagation();
                    self._goToPreviousPage();
                });

                this.$dropdownMenu.on('click', '.bs-multiselect-next-page', function (e) {
                    e.preventDefault();
                    e.stopPropagation();
                    self._goToNextPage();
                });
            }

            // Dropdown shown/hidden events
            this.$container.on('shown.bs.dropdown', function () {
                self.isOpen = true;
                if (self.options.search) {
                    self.$searchInput.focus();
                }
            });

            this.$container.on('hidden.bs.dropdown', function () {
                self.isOpen = false;
                if (self.options.search) {
                    self.$searchInput.val('');
                    self._filterOptions('');
                }
            });

            // Intercept parent form reset event
            const $form = this.$element.closest('form');
            if ($form.length > 0) {
                $form.on('reset.bs-multiselect', function (e) {
                    // Use setTimeout to wait for the browser's default reset behavior to complete
                    setTimeout(function () {
                        self.reset();
                    }, 0);
                });

                // Intercept form submit to trigger validation before submission
                $form.on('submit.bs-multiselect', function (e) {
                    // Validate this element if jQuery Validation is present
                    if ($.validator && $form.data('validator')) {
                        // Use setTimeout to let validation run first
                        setTimeout(function () {
                            const validator = $form.data('validator');
                            const elementName = self.$element.attr('name');

                            // Only update validation state if this element has validation rules
                            if (validator.settings.rules && validator.settings.rules[elementName]) {
                                const hasError = validator.invalid && validator.invalid[elementName];
                                self._updateValidationState(!hasError);
                            }
                        }, 0);
                    }
                });
            }

            // Listen to jQuery Validation events if present
            if ($.validator) {
                this.$element.on('invalid-form.validate', function () {
                    self._updateValidationState(false);
                });

                // Update validation state when element becomes valid
                this.$element.on('change', function () {
                    setTimeout(function () {
                        const $form = self.$element.closest('form');
                        if ($form.length && $form.data('validator')) {
                            const validator = $form.data('validator');
                            const elementName = self.$element.attr('name');

                            // Only update validation state if this element has validation rules
                            if (validator.settings.rules && validator.settings.rules[elementName]) {
                                const hasError = validator.invalid && validator.invalid[elementName];
                                self._updateValidationState(!hasError);
                            }
                        }
                    }, 100);
                });
            }
        }

        /**
         * Handle option checkbox change
         * 
         * @param {jQuery} $checkbox - Checkbox element
         */
        _onOptionChange($checkbox) {
            const value = $checkbox.val();
            const isChecked = $checkbox.prop('checked');

            if (isChecked) {
                // Check max selection limit
                if (this.options.maxSelection > 0 && this.selectedItems.length >= this.options.maxSelection) {
                    $checkbox.prop('checked', false);
                    this._showMaxSelectionWarning();
                    return;
                }

                this.selectedItems.push(value);
            } else {
                const index = this.selectedItems.indexOf(value);
                if (index > -1) {
                    this.selectedItems.splice(index, 1);
                }
            }

            this._updateDisplay();
            this._updateValue();
            this._triggerChange();
            this._updateMaxSelectionState();
        }

        /**
         * Select all options
         */
        _selectAll() {
            const self = this;

            if (this.options.enablePagination) {
                // In pagination mode, select all visible items on current page
                const visibleItems = this._getPaginatedItems();
                const visibleValues = visibleItems.map(item => item[this.options.valueField]);

                if (this.options.maxSelection > 0) {
                    const limit = this.options.maxSelection;
                    const currentCount = this.selectedItems.length;
                    const availableSlots = limit - currentCount;

                    if (availableSlots <= 0) {
                        this._showMaxSelectionWarning();
                        return;
                    }

                    // Select only unchecked items up to the available slots
                    let slotsUsed = 0;
                    visibleValues.forEach(value => {
                        if (!this.selectedItems.includes(value) && slotsUsed < availableSlots) {
                            this.selectedItems.push(value);
                            slotsUsed++;
                        }
                    });

                    if (slotsUsed < visibleValues.filter(v => !this.selectedItems.includes(v)).length) {
                        this._showMaxSelectionWarning();
                    }
                } else {
                    visibleValues.forEach(value => {
                        if (!this.selectedItems.includes(value)) {
                            this.selectedItems.push(value);
                        }
                    });
                }

                // Update checkboxes
                this.$optionsList.find('.form-check-input').each(function () {
                    const $checkbox = $(this);
                    const value = $checkbox.val();
                    if (self.selectedItems.includes(value)) {
                        $checkbox.prop('checked', true);
                    }
                });
            } else {
                // Original select all logic (non-pagination)
                const $visibleCheckboxes = this.$optionsList.find('.bs-multiselect-option:visible .form-check-input').filter(function () {
                    return !$(this).data('original-disabled');
                });

                if (this.options.maxSelection > 0) {
                    const limit = this.options.maxSelection;
                    const currentCount = this.selectedItems.length;
                    const availableSlots = limit - currentCount;

                    if (availableSlots <= 0) {
                        this._showMaxSelectionWarning();
                        return;
                    }

                    // Select only unchecked items up to the available slots
                    let slotsUsed = 0;
                    $visibleCheckboxes.each(function () {
                        const $checkbox = $(this);
                        if (!$checkbox.prop('checked') && slotsUsed < availableSlots) {
                            $checkbox.prop('checked', true);
                            self.selectedItems.push($checkbox.val());
                            slotsUsed++;
                        }
                    });

                    if (slotsUsed < $visibleCheckboxes.filter(':not(:checked)').length) {
                        this._showMaxSelectionWarning();
                    }
                } else {
                    $visibleCheckboxes.each(function () {
                        const $checkbox = $(this);
                        if (!$checkbox.prop('checked')) {
                            $checkbox.prop('checked', true);
                            self.selectedItems.push($checkbox.val());
                        }
                    });
                }
            }

            // Remove duplicates
            this.selectedItems = [...new Set(this.selectedItems)];

            this._updateDisplay();
            this._updateValue();
            this._triggerChange();
            this._updateMaxSelectionState();
        }

        /**
         * Deselect all options
         */
        _deselectAll() {
            this.$optionsList.find('.form-check-input:checked').prop('checked', false);
            this.selectedItems = [];

            this._updateDisplay();
            this._updateValue();
            this._triggerChange();
            this._updateMaxSelectionState();
        }

        /**
         * Filter options based on search query
         * 
         * @param {string} query - Search query
         */
        _filterOptions(query) {
            const searchTerm = query.toLowerCase().trim();

            if (!searchTerm) {
                this.filteredData = [];
                this.currentPage = 1;

                if (this.options.enablePagination) {
                    this._refreshPagination();
                } else {
                    this.$optionsList.find('.bs-multiselect-option, .bs-multiselect-optgroup').show();
                }
                this.$noResults.hide();
                return;
            }

            if (this.options.enablePagination) {
                // For pagination mode, filter data and rebuild
                this.filteredData = this.options.data.filter(item => {
                    if (item.label && item.children) {
                        // Filter optgroup children
                        const filteredChildren = item.children.filter(child => {
                            const text = child[this.options.textField].toLowerCase();
                            return text.includes(searchTerm);
                        });
                        return filteredChildren.length > 0;
                    } else {
                        const text = item[this.options.textField].toLowerCase();
                        return text.includes(searchTerm);
                    }
                });

                // Update filtered data to include only matching children in optgroups
                this.filteredData = this.filteredData.map(item => {
                    if (item.label && item.children) {
                        return {
                            ...item,
                            children: item.children.filter(child => {
                                const text = child[this.options.textField].toLowerCase();
                                return text.includes(searchTerm);
                            })
                        };
                    }
                    return item;
                });

                this.currentPage = 1;
                this._refreshPagination();

                // Check if there are results
                if (this._getFlatItems(this.filteredData).length === 0) {
                    this.$noResults.show();
                } else {
                    this.$noResults.hide();
                }
            } else {
                // Original non-pagination filtering logic
                let visibleCount = 0;

                this.$optionsList.find('.bs-multiselect-option').each(function () {
                    const $option = $(this);
                    const text = $option.find('.form-check-label').text().toLowerCase();

                    if (text.includes(searchTerm)) {
                        $option.show();
                        visibleCount++;
                    } else {
                        $option.hide();
                    }
                });

                // Handle optgroups
                this.$optionsList.find('.bs-multiselect-optgroup').each(function () {
                    const $group = $(this);
                    const visibleChildren = $group.find('.bs-multiselect-option:visible').length;

                    if (visibleChildren > 0) {
                        $group.show();
                    } else {
                        $group.hide();
                    }
                });

                // Show/hide no results message
                if (visibleCount === 0) {
                    this.$noResults.show();
                } else {
                    this.$noResults.hide();
                }
            }
        }

        /**
         * Go to previous page
         */
        _goToPreviousPage() {
            if (this.currentPage > 1) {
                this.currentPage--;
                this._refreshPagination();
            }
        }

        /**
         * Go to next page
         */
        _goToNextPage() {
            const totalPages = this._getTotalPages();
            if (this.currentPage < totalPages) {
                this.currentPage++;
                this._refreshPagination();
            }
        }

        /**
         * Refresh pagination controls and options list
         */
        _refreshPagination() {
            // Rebuild options list
            this.$optionsList.html(this._buildOptions());

            // Update pagination controls
            const totalPages = this._getTotalPages();
            const paginationInfo = this.options.paginationInfoText
                .replace('{current}', this.currentPage)
                .replace('{total}', totalPages);

            this.$dropdownMenu.find('.bs-multiselect-page-info').text(paginationInfo);
            this.$dropdownMenu.find('.bs-multiselect-prev-page').prop('disabled', this.currentPage === 1);
            this.$dropdownMenu.find('.bs-multiselect-next-page').prop('disabled', this.currentPage === totalPages);

            // Restore checked state for selected items
            const self = this;
            this.selectedItems.forEach(value => {
                this.$optionsList.find(`.form-check-input[value="${value}"]`).prop('checked', true);
            });
        }

        /**
         * Update button display with selected items
         */
        _updateDisplay() {
            const count = this.selectedItems.length;

            if (count === 0) {
                this.$placeholder.text(this.options.placeholder);
                this.$badge.hide();
                this.$badge.text('0');
            } else {
                // Get selected items text
                const selectedTexts = [];
                const self = this;

                this.selectedItems.forEach(value => {
                    const $checkbox = this.$optionsList.find(`.form-check-input[value="${value}"]`);
                    const text = $checkbox.next('.form-check-label').text();
                    selectedTexts.push(text);
                });

                // Display selected items (max 3 items in text)
                if (count <= 3) {
                    this.$placeholder.text(selectedTexts.join(', '));
                } else {
                    this.$placeholder.text(`${count} ${this.options.itemsSelectedText}`);
                }

                this.$badge.text(count).show();
            }
        }

        /**
         * Update the original input value
         */
        _updateValue() {
            // Update original element
            if (this.$element.is('select')) {
                // First, deselect all options
                this.$element.find('option').prop('selected', false);

                // Then select only the items in selectedItems array
                if (this.selectedItems.length > 0) {
                    this.selectedItems.forEach(value => {
                        this.$element.find(`option[value="${value}"]`).prop('selected', true);
                    });
                }

                // Important: Set the select's value to ensure proper form submission
                // For multi-select, val() should be an array
                this.$element.val(this.selectedItems.length > 0 ? this.selectedItems : null);
            } else {
                // For input elements, store as JSON (empty array if no items)
                this.$element.val(this.selectedItems.length > 0 ? JSON.stringify(this.selectedItems) : '');
            }

            // Trigger native change event for validation frameworks (like jQuery Validation)
            this.$element.trigger('change');
        }

        /**
         * Trigger change event
         */
        _triggerChange() {
            this.$element.trigger('change.bs-multiselect', [{
                selectedValues: this.selectedItems
            }]);

            // Trigger validation if jQuery Validation is present
            const $form = this.$element.closest('form');
            if ($.validator && $form.length > 0 && $form.data('validator')) {
                const self = this;
                const elementName = self.$element.attr('name');
                const validator = $form.data('validator');

                // Only validate if this element has validation rules
                if (validator.settings.rules && validator.settings.rules[elementName]) {
                    // Force validation on this element
                    const isValid = self.$element.valid();

                    // Update visual state after validation
                    setTimeout(function () {
                        // Check if there are errors after validation
                        const hasError = validator.invalid && validator.invalid[elementName];
                        self._updateValidationState(!hasError);
                    }, 50);
                }
            }
        }

        /**
         * Update validation state visual feedback
         */
        _updateValidationState(isValid) {
            if (isValid) {
                // Remove error styling
                this.$button.removeClass('is-invalid border-danger');
                this.$button.addClass('is-valid');
            } else {
                // Add error styling
                this.$button.removeClass('is-valid');
                this.$button.addClass('is-invalid border-danger');
            }
        }

        /**
         * Set initial selection from element
         */
        _setInitialSelection() {
            const self = this;

            // Priority 1: If element is a select, read directly from DOM (most reliable for TagHelper/HtmlHelper)
            if (this.$element.is('select')) {
                // Only select options that have the 'selected' ATTRIBUTE in HTML (not the property)
                // This is important because browsers may set the first option's selected property to true by default
                this.$element.find('option[selected]').each(function () {
                    const value = $(this).val();
                    if (value && !self.selectedItems.includes(value)) {
                        self.selectedItems.push(value);
                    }
                });
            }
            // Priority 2: Check for selectedValues in options (programmatic initialization)
            else if (this.options.selectedValues && Array.isArray(this.options.selectedValues) && this.options.selectedValues.length > 0) {
                this.selectedItems = [...this.options.selectedValues];
            }
            // Priority 3: Check for pre-selected items in data array
            else if (this.options.data && Array.isArray(this.options.data)) {
                this.options.data.forEach(item => {
                    if (item.children) {
                        // Handle optgroup
                        item.children.forEach(child => {
                            if (child.selected) {
                                const value = child[this.options.valueField];
                                if (value && !self.selectedItems.includes(value)) {
                                    self.selectedItems.push(value);
                                }
                            }
                        });
                    } else if (item.selected) {
                        // Handle regular option
                        const value = item[this.options.valueField];
                        if (value && !this.selectedItems.includes(value)) {
                            self.selectedItems.push(value);
                        }
                    }
                });
            }
            // Priority 4: Read from input element value (JSON format)
            else if (this.$element.val()) {
                try {
                    const values = JSON.parse(this.$element.val());
                    if (Array.isArray(values)) {
                        this.selectedItems = values;
                    }
                } catch (e) {
                    // Invalid JSON, ignore
                }
            }

            // Save initial values for reset functionality
            this.initialSelectedItems = [...this.selectedItems];

            // Update checkboxes
            this.selectedItems.forEach(value => {
                this.$optionsList.find(`.form-check-input[value="${value}"]`).prop('checked', true);
            });

            this._updateDisplay();
            this._updateValue();
            this._updateMaxSelectionState();
        }

        /**
         * Update the state of checkboxes based on max selection limit
         */
        _updateMaxSelectionState() {
            if (this.options.maxSelection > 0) {
                const limitReached = this.selectedItems.length >= this.options.maxSelection;

                // Disable unchecked options if limit is reached
                this.$optionsList.find('.form-check-input').each(function () {
                    const $checkbox = $(this);
                    const isChecked = $checkbox.prop('checked');
                    const isDisabled = $checkbox.data('original-disabled') === true;

                    if (!isDisabled) {
                        if (limitReached && !isChecked) {
                            $checkbox.prop('disabled', true);
                            $checkbox.closest('.bs-multiselect-option').addClass('disabled');
                        } else {
                            $checkbox.prop('disabled', false);
                            $checkbox.closest('.bs-multiselect-option').removeClass('disabled');
                        }
                    }
                });

                // Disable "Select All" button if limit is reached
                const $selectAllBtn = this.$dropdownMenu.find('.bs-multiselect-select-all');
                if (limitReached) {
                    $selectAllBtn.prop('disabled', true).addClass('disabled');
                } else {
                    $selectAllBtn.prop('disabled', false).removeClass('disabled');
                }
            }
        }

        /**
         * Show max selection warning
         */
        _showMaxSelectionWarning() {
            // You can customize this to show a toast, alert, or tooltip
            console.warn(`Maximum selection limit reached (${this.options.maxSelection})`);
        }

        /**
         * Get selected values
         * 
         * @returns {Array} Array of selected values
         */
        getSelectedValues() {
            return [...this.selectedItems];
        }

        /**
         * Set selected values
         * 
         * @param {Array} values - Array of values to select
         */
        setSelectedValues(values) {
            if (!Array.isArray(values)) {
                return;
            }

            // Clear current selection
            this._deselectAll();

            // Set new selection
            const self = this;
            values.forEach(value => {
                const $checkbox = this.$optionsList.find(`.form-check-input[value="${value}"]`);
                if ($checkbox.length && !$checkbox.prop('disabled')) {
                    $checkbox.prop('checked', true);
                    self.selectedItems.push(value);
                }
            });

            this._updateDisplay();
            this._updateValue();
            this._triggerChange();
        }

        /**
         * Refresh the dropdown (reload data and rebuild)
         */
        refresh() {
            const currentSelection = [...this.selectedItems];
            this.$container.remove();
            this._init();
            this.setSelectedValues(currentSelection);
        }

        /**
         * Destroy the plugin
         */
        destroy() {
            // Remove form event handlers
            const $form = this.$element.closest('form');
            if ($form.length > 0) {
                $form.off('reset.bs-multiselect');
                $form.off('submit.bs-multiselect');
            }

            // Remove jQuery Validation event listeners
            if ($.validator) {
                this.$element.off('invalid-form.validate');
            }

            this.$container.remove();

            // Restore original element visibility
            this.$element.css({
                'position': '',
                'left': '',
                'width': '',
                'height': ''
            }).show();

            this.$element.removeData('bootstrapMultiSelect');
            this.$element.removeData('bs-multiselect');
        }

        /**
         * Enable the multiselect
         */
        enable() {
            this.$button.prop('disabled', false);
        }

        /**
         * Disable the multiselect
         */
        disable() {
            this.$button.prop('disabled', true);
        }

        /**
         * Reset the multiselect to initial state (original selected values from HTML)
         * This restores the selections that were present when the plugin was initialized
         */
        reset() {
            // Deselect all checkboxes first
            this.$optionsList.find('.form-check-input:checked').prop('checked', false);

            // Restore to initial selected items (saved during initialization)
            this.selectedItems = this.initialSelectedItems ? [...this.initialSelectedItems] : [];

            // Update checkboxes for initial selections
            this.selectedItems.forEach(value => {
                this.$optionsList.find(`.form-check-input[value="${value}"]`).prop('checked', true);
            });

            // Update the original select element to match initial state
            if (this.$element.is('select')) {
                this.$element.find('option').prop('selected', false);
                this.selectedItems.forEach(value => {
                    this.$element.find(`option[value="${value}"]`).prop('selected', true);
                });
            }

            // Update display (placeholder and badge)
            this._updateDisplay();

            // Update the hidden select value
            this._updateValue();

            // Update max selection state
            this._updateMaxSelectionState();

            // Trigger change event
            this._triggerChange();
        }
    }

    /**
     * jQuery plugin definition
     * 
     * @param {Object|string} options - Options object or method name
     * @returns {jQuery|*} jQuery object for chaining or method result
     */
    $.fn.bootstrapMultiSelect = function (options) {
        const args = Array.prototype.slice.call(arguments, 1);
        let result;

        this.each(function () {
            const $element = $(this);
            let instance = $element.data('bootstrapMultiSelect');

            if (!instance) {
                instance = new BootstrapMultiSelect(this, typeof options === 'object' ? options : {});
                $element.data('bootstrapMultiSelect', instance);
                // Also save with alternative key for easier access
                $element.data('bs-multiselect', instance);
            } else if (typeof options === 'object') {
                // If instance exists and options is an object with data, update the instance
                if (options.data && Array.isArray(options.data)) {
                    instance.options.data = options.data;
                    instance.refresh();
                }
            }

            if (typeof options === 'string' && typeof instance[options] === 'function') {
                const methodResult = instance[options].apply(instance, args);
                // For getter methods, store the result to return
                if (methodResult !== undefined) {
                    result = methodResult;
                }
            }
        });

        // Return the method result if available, otherwise return jQuery object for chaining
        return result !== undefined ? result : this;
    };

    // Auto-initialize elements with data-toggle="bootstrap-multiselect"
    $(document).ready(function () {
        $('[data-toggle="bootstrap-multiselect"]').bootstrapMultiSelect();
    });

})(jQuery);