UNPKG

as-select3

Version:

Modern JavaScript Select Library with advanced features and HTML rendering support

929 lines (763 loc) 35.6 kB
/*! * As-Select3 - Modern JavaScript Select Library * Version: 1.5.0 * Author: Sunil Kumar * Repository: https://github.com/sunil4587/As-select3 * License: MIT */ (function($) { 'use strict'; const DEFAULTS = { placeholder: 'Choose an option...', searchable: true, selectAll: true, clearAll: true, maxSelection: null, remote: null, searchDelay: 300, noResultsText: 'No results found', loadingText: 'Loading...', searchPlaceholder: 'Search options...', selectAllText: 'Select All', clearAllText: 'Clear All', defaultIconClass: 'as-select3-arrow-down', iconPrefix: null, allowHtml: true, fuzzySearch: true, highlightMatches: true, virtualScrolling: false, itemHeight: 32, maxHeight: 300, escapeMarkup: markup => markup, templateResult: null, templateSelection: null, matcher: null }; class AsSelect3 { constructor(element, options = {}) { if (!$(element).length) { throw new Error(`As-Select3: Element "${element}" not found`); } this.$element = $(element); this.element = this.$element[0]; this.isMultiple = this.$element.attr('multiple') !== undefined; this.options = Object.assign({}, DEFAULTS, { placeholder: this.isMultiple ? 'Select options...' : DEFAULTS.placeholder, selectAll: this.isMultiple && DEFAULTS.selectAll, searchable: this.$element.data('search') !== false }, options); this._initContainer(); this._initState(); this.init(); } _initContainer() { let $container = this.$element.closest('.as-select3-container'); if (!$container.length) { $container = $('<div class="as-select3-container"></div>'); this.$element.before($container); $container.append(this.$element); } this.$container = $container; } _initState() { this.isOpen = false; this.focusIndex = -1; this.selectedValues = this.isMultiple ? [] : (this.$element.find('option:selected').val() || null); this.isLoading = false; this.searchTimer = null; this.searchCache = new Map(); this.boundHandlers = {}; } init() { this.createUI(); this.bindEvents(); this.updateFromSelect(); this.storeOriginalOptions(); } createUI() { const triggerHTML = ` <div class="as-select3-trigger" tabindex="0" role="combobox" aria-expanded="false"> <div class="as-select3-selection"> <span class="as-select3-placeholder">${this.options.placeholder}</span> </div> <span class="${this.options.defaultIconClass} as-select3-arrow"></span> </div> `; this.$trigger = $(triggerHTML); this.$selection = this.$trigger.find('.as-select3-selection'); this.$placeholder = this.$trigger.find('.as-select3-placeholder'); this.$arrow = this.$trigger.find('.as-select3-arrow'); this.$container.append(this.$trigger); this.$element.addClass('d-none'); this.createDropdown(); } createDropdown() { this.$dropdown = $('<div class="as-select3-dropdown" role="listbox"></div>'); if (this.options.searchable) { this.createSearch(); } this.createOptionsContainer(); if (this.isMultiple && (this.shouldShowSelectAll() || this.options.clearAll)) { this.createActions(); } this.$container.append(this.$dropdown); } createSearch() { const searchHTML = ` <div class="as-select3-search"> <div class="position-relative w-100"> <input type="text" class="form-control form-control-sm" role="searchbox" placeholder="${this.options.searchPlaceholder}"> <span class="as-select3-close as-select3-search-clear"></span> </div> </div> `; const $searchWrapper = $(searchHTML); this.$searchInput = $searchWrapper.find('input'); this.$dropdown.append($searchWrapper); } createOptionsContainer() { this.$optionsContainer = $('<ul class="as-select3-options"></ul>'); this.$element.find('option').each((index, option) => { this.$optionsContainer.append(this.createOptionElement(option, index)); }); this.$dropdown.append(this.$optionsContainer); } createOptionElement(optionData, index) { const isHtmlOption = optionData instanceof HTMLOptionElement; const data = isHtmlOption ? { value: optionData.value, text: optionData.text, selected: optionData.selected, icon: $(optionData).data('icon') || $(optionData).attr('data-icon'), html: $(optionData).data('html') || $(optionData).attr('data-html') || $(optionData).html(), disabled: optionData.disabled } : optionData; const $option = $('<li class="as-select3-option" role="option" tabindex="-1"></li>') .addClass(data.selected ? 'selected' : '') .addClass(data.disabled ? 'disabled' : '') .attr({ 'data-value': data.value, 'data-index': index, 'data-text': data.text, 'data-icon': data.icon || '', 'data-html': data.html || '', 'aria-selected': data.selected.toString() }); const $optionLeft = $('<div class="as-select3-option-left"></div>'); if (data.icon) { const $iconContainer = $('<div class="as-select3-option-icon"></div>'); this.addIcon($iconContainer, data.icon); $optionLeft.append($iconContainer); } $optionLeft.append($('<span class="as-select3-option-text"></span>').text(data.text)); $option.append($optionLeft); if (this.isMultiple) { $option.append($('<input type="checkbox" class="form-check-input">').prop('checked', data.selected)); } return $option; } addIcon($container, icon, size = '16px') { if (!icon) return; if (icon.match(/^(https?:|data:|\.?\/)/)) { $container.append($('<img>').attr({ src: icon, alt: '' }).css({ width: size, height: size, objectFit: 'cover' })); } else if (this.isIconClass(icon)) { const $icon = $('<i></i>').addClass(icon); $container.append($icon); } else { $container.html(icon); } } isIconClass(icon) { const iconPrefixes = ['bi-', 'fa-', 'fas-', 'far-', 'fab-', 'material-icons', 'icon-']; return iconPrefixes.some(prefix => icon.includes(prefix)) || (this.options.iconPrefix && icon.startsWith(this.options.iconPrefix)); } shouldShowSelectAll() { if (!this.options.selectAll) return false; if (!this.options.maxSelection) return true; const availableOptionsCount = this.$element.find('option:not(:disabled)').length; return this.options.maxSelection >= availableOptionsCount; } createActions() { if (!this.isMultiple) return; const showSelectAll = this.shouldShowSelectAll(); const actionsHTML = ` <div class="as-select3-actions"> ${showSelectAll ? `<button type="button" class="btn btn-sm btn-outline-primary select-all">${this.options.selectAllText}</button>` : ''} ${this.options.clearAll ? `<button type="button" class="btn btn-sm btn-outline-secondary clear-all">${this.options.clearAllText}</button>` : ''} </div> `; this.$dropdown.append(actionsHTML); } bindEvents() { this.boundHandlers = { triggerClick: this.handleTriggerClick.bind(this), documentClick: this.handleDocumentClick.bind(this), keyDown: this.handleKeyDown.bind(this), optionClick: this.handleOptionClick.bind(this), searchInput: this.debounce(this.handleSearchInput.bind(this), this.options.searchDelay), clearClick: this.handleClearClick.bind(this), actionClick: this.handleActionClick.bind(this) }; this.$trigger.on('click', this.boundHandlers.triggerClick); this.$trigger.on('keydown', this.boundHandlers.keyDown); this.$container.on('click', '.as-select3-option', this.boundHandlers.optionClick); this.$container.on('click', '.as-select3-clear, .as-select3-tag-remove, .as-select3-search-clear', this.boundHandlers.clearClick); this.$container.on('click', '.select-all, .clear-all', this.boundHandlers.actionClick); if (this.$searchInput) { this.$searchInput.on('input', this.boundHandlers.searchInput); } $(document).on('click.asSelect3', this.boundHandlers.documentClick); } handleTriggerClick(e) { if ($(e.target).hasClass('as-select3-clear')) return; e.preventDefault(); this.toggle(); } handleDocumentClick(e) { if (this.$container.is(e.target) || this.$container.has(e.target).length) return; this.close(); } handleKeyDown(e) { const keyActions = { 'Enter': () => this.isOpen ? this.selectFocused() : this.open(), ' ': () => this.isOpen ? this.selectFocused() : this.open(), 'Escape': () => this.isOpen && this.close(), 'ArrowDown': () => this.isOpen ? this.focusNext() : this.open(), 'ArrowUp': () => this.isOpen ? this.focusPrevious() : this.open() }; if (keyActions[e.key]) { e.preventDefault(); keyActions[e.key](); } } handleOptionClick(e) { const $option = $(e.currentTarget); if ($option.hasClass('disabled')) return; this.toggleOption($option); } handleSearchInput(e) { const query = e.target.value; if (this.options.remote && typeof this.options.remote === 'function') { this.remoteSearch(query); } else { this.search(query); } } handleClearClick(e) { e.stopPropagation(); const $target = $(e.target); if ($target.hasClass('as-select3-search-clear')) { this.$searchInput.val('').trigger('input').focus(); } else if ($target.hasClass('as-select3-tag-remove')) { const value = $target.closest('.as-select3-tag').data('value'); this.removeTag(value); } else if ($target.hasClass('as-select3-clear')) { this.clearSingle(); } } handleActionClick(e) { const $target = $(e.target); if ($target.hasClass('select-all')) { this.selectAll(); } else if ($target.hasClass('clear-all')) { this.clearAll(); } } toggle() { this.isOpen ? this.close() : this.open(); } open() { if (this.isOpen) return; this.isOpen = true; this.$dropdown.addClass('show'); this.$trigger.addClass('active').attr('aria-expanded', 'true'); this.$container.addClass('active'); if (this.$searchInput) { requestAnimationFrame(() => this.$searchInput.focus()); } this.$element.trigger('asSelect3:open'); } close() { if (!this.isOpen) return; this.isOpen = false; this.$dropdown.removeClass('show'); this.$trigger.removeClass('active').attr('aria-expanded', 'false'); this.$container.removeClass('active'); this.focusIndex = -1; if (this.$searchInput) { this.$searchInput.val(''); this.search(''); } this.$element.trigger('asSelect3:close'); } toggleOption($option) { if (!$option?.length || $option.hasClass('disabled')) return; const value = $option.data('value'); const isSelected = $option.hasClass('selected'); if (isSelected) { this.deselectOption($option, value); } else { this.selectOption($option, value); } this.updateSelection(); this.$element.trigger('change').trigger('asSelect3:change', { value: this.getValue() }); } selectOption($option, value) { if (this.isMultiple) { if (this.options.maxSelection && this.selectedValues.length >= this.options.maxSelection) { this.$element.trigger('asSelect3:maxselection', { max: this.options.maxSelection }); return; } this.selectedValues.push(value); $option.addClass('selected').attr('aria-selected', 'true'); $option.find('input[type="checkbox"]').prop('checked', true); this.updateOptionStates(); } else { this.$optionsContainer.find('.as-select3-option').removeClass('selected').attr('aria-selected', 'false'); this.$element.find('option').prop('selected', false); this.selectedValues = value; $option.addClass('selected').attr('aria-selected', 'true'); this.close(); } this.$element.find(`option[value="${value}"]`).prop('selected', true); } updateOptionStates() { if (this.isMultiple && this.options.maxSelection) { const isMaxReached = this.selectedValues.length >= this.options.maxSelection; this.$optionsContainer.find('.as-select3-option').each((index, option) => { const $option = $(option); const isSelected = $option.hasClass('selected'); const $checkbox = $option.find('input[type="checkbox"]'); if (isMaxReached && !isSelected) { $option.addClass('disabled').attr('aria-disabled', 'true'); $checkbox.prop('disabled', true); } else { $option.removeClass('disabled').attr('aria-disabled', 'false'); $checkbox.prop('disabled', false); } }); } } deselectOption($option, value) { $option.removeClass('selected').attr('aria-selected', 'false'); this.$element.find(`option[value="${value}"]`).prop('selected', false); if (this.isMultiple) { $option.find('input[type="checkbox"]').prop('checked', false); this.selectedValues = this.selectedValues.filter(v => v !== value); this.updateOptionStates(); } else { this.selectedValues = null; } } search(query) { if (this.searchCache.has(query)) { this.applySearchResults(this.searchCache.get(query)); return; } const $options = this.$optionsContainer.find('.as-select3-option'); const results = []; let hasResults = false; $options.each((index, option) => { const $option = $(option); const text = $option.data('text') || $option.text(); const matches = this.matchText(text, query); if (matches) { hasResults = true; results.push($option); if (this.options.highlightMatches && query) { this.highlightText($option, text, query); } } $option.toggle(matches); }); this.searchCache.set(query, results); this.toggleNoResults(!hasResults && query.length > 0); } applySearchResults(results) { const $options = this.$optionsContainer.find('.as-select3-option'); $options.each((index, option) => { const $option = $(option); const isMatch = results.some(result => result.is($option)); $option.toggle(isMatch); }); } matchText(text, query) { if (!query) return true; const textLower = text.toLowerCase(); const queryLower = query.toLowerCase(); return textLower.includes(queryLower); } highlightText($option, text, query) { const $textElement = $option.find('.as-select3-option-text'); if (!query || !$textElement.length) return; const regex = new RegExp(`(${this.escapeRegex(query)})`, 'gi'); const highlighted = text.replace(regex, '<mark class="as-select3-highlight">$1</mark>'); $textElement.html(highlighted); } escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async remoteSearch(query) { if (!this.options.remote || typeof this.options.remote !== 'function') return; this.showLoading(); try { const results = await this.options.remote(query); if (Array.isArray(results)) { this.populateOptions(results); } else if (results && results.options && Array.isArray(results.options)) { this.populateOptions(results.options); } else if (results && results.data && Array.isArray(results.data)) { this.populateOptions(results.data); } else { console.warn('Invalid remote response:', results); this.showNoResults(); } } catch (error) { console.error('Remote search error:', error); this.showNoResults(); this.$element.trigger('asSelect3:error', { error }); } finally { this.hideLoading(); } } showLoading() { this.isLoading = true; this.hideNoResults(); this.$optionsContainer.find('.as-select3-option').hide(); if (!this.$optionsContainer.find('.as-select3-loading').length) { this.$optionsContainer.append(` <li class="as-select3-loading"> <div class="spinner-border spinner-border-sm me-2"></div> ${this.options.loadingText} </li> `); } } hideLoading() { this.isLoading = false; this.$optionsContainer.find('.as-select3-loading').remove(); this.$optionsContainer.find('.as-select3-option').show(); } showNoResults() { this.toggleNoResults(true); } hideNoResults() { this.toggleNoResults(false); } populateOptions(options = []) { // Clear existing options this.$optionsContainer.find('.as-select3-option').remove(); this.$optionsContainer.find('.as-select3-no-results, .as-select3-loading').remove(); // Clear original select options for remote data if (this.options.remote) { this.$element.empty(); } if (!options || options.length === 0) { this.showNoResults(); return; } options.forEach((option, index) => { // Normalize option data const optionData = { value: option.value || option.id, text: option.text || option.label || option.name || option.title, selected: !!option.selected, disabled: !!option.disabled, icon: option.icon, html: option.html }; // Create the option element and add to dropdown const $optionElement = this.createOptionElement(optionData, index); this.$optionsContainer.append($optionElement); // Also add to the original select element for form submission const $selectOption = $('<option></option>') .val(optionData.value) .text(optionData.text) .prop('selected', optionData.selected); if (optionData.icon) $selectOption.attr('data-icon', optionData.icon); if (optionData.html) $selectOption.attr('data-html', optionData.html); if (optionData.disabled) $selectOption.prop('disabled', true); this.$element.append($selectOption); }); this.hideNoResults(); // Trigger event to notify that options have been populated this.$element.trigger('asSelect3:optionsPopulated', { options: options }); } toggleNoResults(show) { this.$optionsContainer.find('.as-select3-no-results').remove(); if (show) { this.$optionsContainer.append(` <li class="as-select3-no-results"> ${this.options.noResultsText} </li> `); } } focusNext() { const $options = this.$optionsContainer.find('.as-select3-option:visible:not(.disabled)'); if (!$options.length) return; this.$optionsContainer.find('.focused').removeClass('focused'); this.focusIndex = this.focusIndex < $options.length - 1 ? this.focusIndex + 1 : 0; $options.eq(this.focusIndex).addClass('focused'); } focusPrevious() { const $options = this.$optionsContainer.find('.as-select3-option:visible:not(.disabled)'); if (!$options.length) return; this.$optionsContainer.find('.focused').removeClass('focused'); this.focusIndex = this.focusIndex > 0 ? this.focusIndex - 1 : $options.length - 1; $options.eq(this.focusIndex).addClass('focused'); } selectFocused() { const $focused = this.$optionsContainer.find('.focused'); if ($focused.length) { this.toggleOption($focused); } } updateSelection() { this.$selection.empty(); if (this.isMultiple) { if (this.selectedValues.length > 0) { this.selectedValues.forEach(value => { const $originalOption = this.$element.find(`option[value="${value}"]`); const $dropdownOption = this.$optionsContainer.find(`[data-value="${value}"]`); if ($originalOption.length) { const text = $originalOption.text(); const icon = $originalOption.attr('data-icon') || $dropdownOption.data('icon'); this.$selection.append(this.createTag(text, value, icon)); } }); } else { this.$selection.append(this.$placeholder); } } else { if (this.selectedValues) { const $originalOption = this.$element.find(`option[value="${this.selectedValues}"]`); const $dropdownOption = this.$optionsContainer.find(`[data-value="${this.selectedValues}"]`); if ($originalOption.length) { const text = $originalOption.text(); const icon = $originalOption.attr('data-icon') || $dropdownOption.data('icon'); this.$selection.append(this.createSingleValue(text, icon)); } } else { this.$selection.append(this.$placeholder); } } } createTag(text, value, icon) { const $tag = $('<span class="as-select3-tag"></span>').data('value', value); if (icon) { const $icon = $('<div class="as-select3-tag-icon"></div>'); this.addIcon($icon, icon); $tag.append($icon); } $tag.append($('<span class="as-select3-tag-text"></span>').text(text).attr('title', text)); $tag.append($('<span class="as-select3-close as-select3-tag-remove"></span>')); return $tag; } createSingleValue(text, icon) { const $container = $('<div class="as-select3-single-container"></div>'); const $value = $('<span class="as-select3-single-value"></span>'); if (icon) { const $icon = $('<div class="as-select3-single-icon"></div>'); this.addIcon($icon, icon); $value.append($icon); } $value.append($('<span></span>').text(text)); $container.append($value); $container.append($('<span class="as-select3-close as-select3-clear"></span>')); return $container; } selectAll() { if (!this.isMultiple) return; this.$element.find('option:not(:disabled)').prop('selected', true); this.selectedValues = this.$element.find('option:selected').map((i, opt) => opt.value).get(); this.$optionsContainer.find('.as-select3-option:not(.disabled)') .addClass('selected') .attr('aria-selected', 'true') .find('input[type="checkbox"]').prop('checked', true); this.updateSelection(); this.$element.trigger('change').trigger('asSelect3:selectall'); } clearAll() { this.$element.find('option').prop('selected', false); this.selectedValues = this.isMultiple ? [] : null; this.$optionsContainer.find('.as-select3-option') .removeClass('selected') .attr('aria-selected', 'false') .find('input[type="checkbox"]').prop('checked', false); this.updateSelection(); this.$element.trigger('change').trigger('asSelect3:clearall'); } clearSingle() { this.selectedValues = null; this.$element.find('option').prop('selected', false); this.$optionsContainer.find('.as-select3-option').removeClass('selected').attr('aria-selected', 'false'); this.updateSelection(); this.$element.trigger('change').trigger('asSelect3:cleared'); } removeTag(value) { if (!this.isMultiple) return; this.selectedValues = this.selectedValues.filter(v => v !== value); this.$element.find(`option[value="${value}"]`).prop('selected', false); this.$optionsContainer.find(`[data-value="${value}"]`) .removeClass('selected') .attr('aria-selected', 'false') .find('input[type="checkbox"]').prop('checked', false); this.updateSelection(); this.$element.trigger('change').trigger('asSelect3:tagremoved', { value }); } updateFromSelect() { if (this.isMultiple) { this.selectedValues = this.$element.find('option:selected').map((i, opt) => opt.value).get(); } else { this.selectedValues = this.$element.find('option:selected').val() || null; } this.updateSelection(); } storeOriginalOptions() { this.originalOptions = this.$element.find('option').map(function() { return { value: this.value, text: this.text, selected: this.selected }; }).get(); } getValue() { return this.selectedValues; } setValue(value) { if (Array.isArray(value) && this.isMultiple) { this.$element.find('option').prop('selected', false); value.forEach(v => { this.$element.find(`option[value="${v}"]`).prop('selected', true); }); } else if (!Array.isArray(value) && !this.isMultiple) { this.$element.find('option').prop('selected', false); this.$element.find(`option[value="${value}"]`).prop('selected', true); } this.updateFromSelect(); this.$element.trigger('change').trigger('asSelect3:valuechanged', { value: this.getValue() }); } debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } addOption(option) { const $option = $('<option></option>') .val(option.value) .text(option.text || option.label) .prop('selected', !!option.selected); if (option.icon) $option.attr('data-icon', option.icon); if (option.html) $option.attr('data-html', option.html); if (option.disabled) $option.prop('disabled', !!option.disabled); this.$element.append($option); this.$optionsContainer.append(this.createOptionElement(option, this.$element.find('option').length - 1)); if (option.selected) { this.updateFromSelect(); } this.$element.trigger('asSelect3:optionadded', { option }); return $option; } removeOption(value) { this.$element.find(`option[value="${value}"]`).remove(); this.$optionsContainer.find(`[data-value="${value}"]`).remove(); if (this.isMultiple) { this.selectedValues = this.selectedValues.filter(v => v !== value); } else if (this.selectedValues === value) { this.selectedValues = null; } this.updateSelection(); this.$element.trigger('asSelect3:optionremoved', { value }); } enable() { this.$container.removeClass('disabled'); this.$trigger.attr('tabindex', '0'); this.$element.prop('disabled', false); } disable() { this.close(); this.$container.addClass('disabled'); this.$trigger.attr('tabindex', '-1'); this.$element.prop('disabled', true); } refresh() { this.$optionsContainer.empty(); this.$element.find('option').each((index, option) => { this.$optionsContainer.append(this.createOptionElement(option, index)); }); this.updateFromSelect(); } reinitializeFromDOM() { const currentValue = this.getValue(); const currentOptions = this.options; this.destroy(); const newInstance = new AsSelect3(this.element, currentOptions); this.element._asSelect3 = newInstance; if (currentValue) { setTimeout(() => { try { newInstance.setValue(currentValue); } catch (e) { console.warn('As-Select3: Could not restore value after reinitialize'); } }, 10); } return newInstance; } destroy() { if (this.searchTimer) { clearTimeout(this.searchTimer); } if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } this.close(); Object.values(this.boundHandlers).forEach(handler => { this.$trigger.off('click', handler); this.$trigger.off('keydown', handler); this.$container.off('click', handler); if (this.$searchInput) { this.$searchInput.off('input', handler); } }); $(document).off('click.asSelect3', this.boundHandlers.documentClick); this.searchCache.clear(); this.$element.removeClass('d-none').insertAfter(this.$container); this.$container.remove(); delete this.element._asSelect3; return this.$element; } } $.fn.asSelect3 = function(options) { return this.each(function() { if (!this._asSelect3) { this._asSelect3 = new AsSelect3(this, options); } }); }; AsSelect3.autoInit = function(selector = '.as-select3-container select') { return $(selector).filter(function() { return !this._asSelect3; }).map(function() { const instance = new AsSelect3(this); this._asSelect3 = instance; return instance; }).get(); }; window.AsSelect3 = AsSelect3; })(jQuery);