import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DoCheck, EventEmitter, forwardRef, Input, IterableDiffers, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, } from '@angular/core'; import { AbstractControl, ControlValueAccessor, FormBuilder, FormControl, NG_VALUE_ACCESSOR, Validator, } from '@angular/forms'; import { takeUntil } from 'rxjs/operators'; import { MultiSelectSearchFilter } from './search-filter.pipe'; import { IMultiSelectOption, IMultiSelectSettings, IMultiSelectTexts, } from './types'; import { Subject, Observable } from 'rxjs'; const MULTISELECT_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NgxDropdownMultiselectComponent), multi: true, }; // tslint:disable-next-line: no-conflicting-lifecycle @Component({ selector: 'ngx-bootstrap-multiselect', templateUrl: './ngx-bootstrap-multiselect.component.html', styleUrls: ['./ngx-bootstrap-multiselect.component.css'], providers: [MULTISELECT_VALUE_ACCESSOR, MultiSelectSearchFilter], changeDetection: ChangeDetectionStrategy.OnPush }) export class NgxDropdownMultiselectComponent implements OnInit, OnChanges, DoCheck, OnDestroy, ControlValueAccessor, Validator { private localIsVisible = false; private workerDocClicked = false; filterControl: FormControl = this.fb.control(''); @Input() options: Array; @Input() settings: IMultiSelectSettings; @Input() texts: IMultiSelectTexts; @Input() disabled = false; @Input() disabledSelection = false; @Input() searchFunction: (str: string) => RegExp = this._escapeRegExp; @Output() selectionLimitReached = new EventEmitter(); @Output() dropdownClosed = new EventEmitter(); @Output() dropdownOpened = new EventEmitter(); @Output() added = new EventEmitter(); @Output() removed = new EventEmitter(); @Output() lazyLoad = new EventEmitter(); @Output() filter: Observable = this.filterControl.valueChanges; get focusBack(): boolean { return this.settings.focusBack && this._focusBack; } destroyed$ = new Subject(); filteredOptions: IMultiSelectOption[] = []; lazyLoadOptions: IMultiSelectOption[] = []; renderFilteredOptions: IMultiSelectOption[] = []; model: any[] = []; prevModel: any[] = []; parents: any[]; title: string; differ: any; numSelected = 0; set isVisible(val: boolean) { this.localIsVisible = val; this.workerDocClicked = val ? false : this.workerDocClicked; } get isVisible(): boolean { return this.localIsVisible; } renderItems = true; checkAllSearchRegister = new Set(); checkAllStatus = false; loadedValueIds = []; _focusBack = false; focusedItem: IMultiSelectOption | undefined; defaultSettings: IMultiSelectSettings = { closeOnClickOutside: true, pullRight: false, enableSearch: false, searchRenderLimit: 0, searchRenderAfter: 1, searchMaxLimit: 0, searchMaxRenderedItems: 0, checkedStyle: 'checkboxes', buttonClasses: 'btn btn-primary dropdown-toggle', containerClasses: 'dropdown-inline', selectionLimit: 0, minSelectionLimit: 0, closeOnSelect: false, autoUnselect: false, showCheckAll: false, showUncheckAll: false, fixedTitle: false, dynamicTitleMaxItems: 3, maxHeight: '300px', isLazyLoad: false, stopScrollPropagation: false, loadViewDistance: 1, selectAddedValues: false, ignoreLabels: false, maintainSelectionOrderInTitle: false, focusBack: true }; defaultTexts: IMultiSelectTexts = { checkAll: 'Select all', uncheckAll: 'Unselect all', checked: 'selected', checkedPlural: 'selected', searchPlaceholder: 'Search...', searchEmptyResult: 'Nothing found...', searchNoRenderText: 'Type in search box to see results...', defaultTitle: 'Select', allSelected: 'All selected', }; get searchLimit(): number | undefined { return this.settings.searchRenderLimit; } get searchRenderAfter(): number | undefined { return this.settings.searchRenderAfter; } get searchLimitApplied(): boolean { return this.searchLimit > 0 && this.options.length > this.searchLimit; } constructor( private fb: FormBuilder, private searchFilter: MultiSelectSearchFilter, differs: IterableDiffers, private cdRef: ChangeDetectorRef ) { this.differ = differs.find([]).create(null); this.settings = this.defaultSettings; this.texts = this.defaultTexts; } clickedOutside(): void { if (!this.isVisible || !this.settings.closeOnClickOutside) { return; } this.isVisible = false; this._focusBack = true; this.dropdownClosed.emit(); } getItemStyle(option: IMultiSelectOption): any { const style = {}; if (!option.isLabel) { style['cursor'] = 'pointer'; } if (option.disabled) { style['cursor'] = 'default'; } } getItemStyleSelectionDisabled(): any { if (this.disabledSelection) { return { cursor: 'default' }; } } ngOnInit(): void { this.title = this.texts.defaultTitle || ''; this.filterControl.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(() => { this.updateRenderItems(); if (this.settings.isLazyLoad) { this.load(); } }); } ngOnChanges(changes: SimpleChanges) { if (changes['options']) { this.options = this.options || []; this.parents = this.options .filter(option => typeof option.parentId === 'number') .map(option => option.parentId); this.updateRenderItems(); if ( this.settings.isLazyLoad && this.settings.selectAddedValues && this.loadedValueIds.length === 0 ) { this.loadedValueIds = this.loadedValueIds.concat( changes.options.currentValue.map(value => value.id) ); } if ( this.settings.isLazyLoad && this.settings.selectAddedValues && changes.options.previousValue ) { const addedValues = changes.options.currentValue.filter( value => this.loadedValueIds.indexOf(value.id) === -1 ); this.loadedValueIds.concat(addedValues.map(value => value.id)); if (this.checkAllStatus) { this.addChecks(addedValues); } else if (this.checkAllSearchRegister.size > 0) { this.checkAllSearchRegister.forEach((searchValue: string) => this.addChecks(this.applyFilters(addedValues, searchValue)) ); } } if (this.texts) { this.updateTitle(); } this.fireModelChange(); } if (changes['settings']) { this.settings = { ...this.defaultSettings, ...this.settings }; } if (changes['texts']) { this.texts = { ...this.defaultTexts, ...this.texts }; if (!changes['texts'].isFirstChange()) { this.updateTitle(); } } } ngOnDestroy() { this.destroyed$.next(false); } updateRenderItems() { this.renderItems = !this.searchLimitApplied || this.filterControl.value.length >= this.searchRenderAfter; this.filteredOptions = this.applyFilters( this.options, this.settings.isLazyLoad ? '' : this.filterControl.value ); this.renderFilteredOptions = this.renderItems ? this.filteredOptions : []; this.focusedItem = undefined; } applyFilters(options: IMultiSelectOption[], value: string): IMultiSelectOption[] { return this.searchFilter.transform( options, value, this.settings.searchMaxLimit, this.settings.searchMaxRenderedItems, this.searchFunction ); } fireModelChange(): void { if (this.model != this.prevModel) { this.prevModel = this.model; this.onModelChange(this.model); this.onModelTouched(); this.cdRef.markForCheck(); } } onModelChange: Function = (_: any) => { }; onModelTouched: Function = () => { }; writeValue(value: any): void { if (value !== undefined && value !== null) { this.model = Array.isArray(value) ? value : [value]; this.ngDoCheck(); } else { this.model = []; } } registerOnChange(fn: Function): void { this.onModelChange = fn; } registerOnTouched(fn: Function): void { this.onModelTouched = fn; } setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } ngDoCheck() { const changes = this.differ.diff(this.model); if (changes) { this.updateNumSelected(); this.updateTitle(); } } validate(_c: AbstractControl): { [key: string]: any } { if (this.model && this.model.length) { return { required: { valid: false } }; } if (this.options.filter(o => this.model.indexOf(o.id) && !o.disabled).length === 0) { return { selection: { valid: false } }; } return null; } registerOnValidatorChange(_fn: () => void): void { throw new Error('Method not implemented.'); } clearSearch(event: Event) { this.maybeStopPropagation(event); this.filterControl.setValue(''); } toggleDropdown(e?: Event) { if (this.isVisible) { this._focusBack = true; } this.isVisible = !this.isVisible; this.isVisible ? this.dropdownOpened.emit() : this.dropdownClosed.emit(); this.focusedItem = undefined; } closeDropdown(e?: Event) { this.isVisible = true; this.toggleDropdown(e); } isSelected(option: IMultiSelectOption): boolean { return this.model && this.model.indexOf(option.id) > -1; } setSelected(_event: Event, option: IMultiSelectOption) { if (option.isLabel) { return; } if (option.disabled) { return; } if (this.disabledSelection) { return; } setTimeout(() => { this.maybeStopPropagation(_event); this.maybePreventDefault(_event); const index = this.model.indexOf(option.id); const isAtSelectionLimit = this.settings.selectionLimit > 0 && this.model.length >= this.settings.selectionLimit; const removeItem = (idx, id): void => { this.model.splice(idx, 1); this.removed.emit(id); if ( this.settings.isLazyLoad && this.lazyLoadOptions.some(val => val.id === id) ) { this.lazyLoadOptions.splice( this.lazyLoadOptions.indexOf( this.lazyLoadOptions.find(val => val.id === id) ), 1 ); } }; if (index > -1) { if ( this.settings.minSelectionLimit === undefined || this.numSelected > this.settings.minSelectionLimit ) { removeItem(index, option.id); } const parentIndex = option.parentId && this.model.indexOf(option.parentId); if (parentIndex > -1) { removeItem(parentIndex, option.parentId); } else if (this.parents.indexOf(option.id) > -1) { this.options .filter( child => this.model.indexOf(child.id) > -1 && child.parentId === option.id ) .forEach(child => removeItem(this.model.indexOf(child.id), child.id) ); } } else if (isAtSelectionLimit && !this.settings.autoUnselect) { this.selectionLimitReached.emit(this.model.length); return; } else { const addItem = (id): void => { this.model.push(id); this.added.emit(id); if ( this.settings.isLazyLoad && !this.lazyLoadOptions.some(val => val.id === id) ) { this.lazyLoadOptions.push(option); } }; addItem(option.id); if (!isAtSelectionLimit) { if (option.parentId && !this.settings.ignoreLabels) { const children = this.options.filter( child => child.id !== option.id && child.parentId === option.parentId ); if (children.every(child => this.model.indexOf(child.id) > -1)) { addItem(option.parentId); } } else if (this.parents.indexOf(option.id) > -1) { const children = this.options.filter( child => this.model.indexOf(child.id) < 0 && child.parentId === option.id ); children.forEach(child => addItem(child.id)); } } else { removeItem(0, this.model[0]); } } if (this.settings.closeOnSelect) { this.toggleDropdown(); } this.model = this.model.slice(); this.fireModelChange(); }, 0) } updateNumSelected() { this.numSelected = this.model.filter(id => this.parents.indexOf(id) < 0).length || 0; } updateTitle() { let numSelectedOptions = this.options.length; if (this.settings.ignoreLabels) { numSelectedOptions = this.options.filter( (option: IMultiSelectOption) => !option.isLabel ).length; } if (this.numSelected === 0 || this.settings.fixedTitle) { this.title = this.texts ? this.texts.defaultTitle : ''; } else if ( this.settings.displayAllSelectedText && this.model.length === numSelectedOptions ) { this.title = this.texts ? this.texts.allSelected : ''; } else if ( this.settings.dynamicTitleMaxItems && this.settings.dynamicTitleMaxItems >= this.numSelected ) { const useOptions = this.settings.isLazyLoad && this.lazyLoadOptions.length ? this.lazyLoadOptions : this.options; let titleSelections: Array; if (this.settings.maintainSelectionOrderInTitle) { const optionIds = useOptions.map((selectOption: IMultiSelectOption, idx: number) => selectOption.id); titleSelections = this.model .map((selectedId) => optionIds.indexOf(selectedId)) .filter((optionIndex) => optionIndex > -1) .map((optionIndex) => useOptions[optionIndex]); } else { titleSelections = useOptions.filter((option: IMultiSelectOption) => this.model.indexOf(option.id) > -1); } this.title = titleSelections.map((option: IMultiSelectOption) => option.name).join(', '); } else { this.title = this.numSelected + ' ' + (this.numSelected === 1 ? this.texts.checked : this.texts.checkedPlural); } this.cdRef.markForCheck(); } searchFilterApplied() { return ( this.settings.enableSearch && this.filterControl.value && this.filterControl.value.length > 0 ); } addChecks(options) { const checkedOptions = options .filter((option: IMultiSelectOption) => { if ( !option.disabled && ( this.model.indexOf(option.id) === -1 && !(this.settings.ignoreLabels && option.isLabel) ) ) { this.added.emit(option.id); return true; } return false; }) .map((option: IMultiSelectOption) => option.id); this.model = this.model.concat(checkedOptions); } checkAll(): void { if (!this.disabledSelection) { this.addChecks( !this.searchFilterApplied() ? this.options : this.filteredOptions ); if (this.settings.isLazyLoad && this.settings.selectAddedValues) { if (this.searchFilterApplied() && !this.checkAllStatus) { this.checkAllSearchRegister.add(this.filterControl.value); } else { this.checkAllSearchRegister.clear(); this.checkAllStatus = true; } this.load(); } this.fireModelChange(); } } uncheckAll(): void { if (!this.disabledSelection) { const checkedOptions = this.model; let unCheckedOptions = !this.searchFilterApplied() ? this.model : this.filteredOptions.map((option: IMultiSelectOption) => option.id); // set unchecked options only to the ones that were checked unCheckedOptions = checkedOptions.filter(item => unCheckedOptions.indexOf(item) > -1); this.model = this.model.filter((id: number) => { if ( (unCheckedOptions.indexOf(id) < 0 && this.settings.minSelectionLimit === undefined) || unCheckedOptions.indexOf(id) < this.settings.minSelectionLimit ) { return true; } else { this.removed.emit(id); return false; } }); if (this.settings.isLazyLoad && this.settings.selectAddedValues) { if (this.searchFilterApplied()) { if (this.checkAllSearchRegister.has(this.filterControl.value)) { this.checkAllSearchRegister.delete(this.filterControl.value); this.checkAllSearchRegister.forEach(function(searchTerm) { const filterOptions = this.applyFilters(this.options.filter(option => unCheckedOptions.indexOf(option.id) > -1), searchTerm); this.addChecks(filterOptions); }); } } else { this.checkAllSearchRegister.clear(); this.checkAllStatus = false; } this.load(); } this.fireModelChange(); } } preventCheckboxCheck(event: Event, option: IMultiSelectOption): void { if ( option.disabled || ( this.settings.selectionLimit && !this.settings.autoUnselect && this.model.length >= this.settings.selectionLimit && this.model.indexOf(option.id) === -1 && this.maybePreventDefault(event) ) ) { this.maybePreventDefault(event); } } isCheckboxDisabled(option?: IMultiSelectOption): boolean { return this.disabledSelection || option && option.disabled; } checkScrollPosition(ev): void { const scrollTop = ev.target.scrollTop; const scrollHeight = ev.target.scrollHeight; const scrollElementHeight = ev.target.clientHeight; const roundingPixel = 1; const gutterPixel = 1; if ( scrollTop >= scrollHeight - (1 + this.settings.loadViewDistance) * scrollElementHeight - roundingPixel - gutterPixel ) { this.load(); } } checkScrollPropagation(ev, element): void { const scrollTop = element.scrollTop; const scrollHeight = element.scrollHeight; const scrollElementHeight = element.clientHeight; if ( (ev.deltaY > 0 && scrollTop + scrollElementHeight >= scrollHeight) || (ev.deltaY < 0 && scrollTop <= 0) ) { ev = ev || window.event; this.maybePreventDefault(ev); ev.returnValue = false; } } trackById(idx: number, selectOption: IMultiSelectOption): void { return selectOption.id; } load(): void { this.lazyLoad.emit({ length: this.options.length, filter: this.filterControl.value, checkAllSearches: this.checkAllSearchRegister, checkAllStatus: this.checkAllStatus, }); } focusItem(dir: number, e?: Event): void { if (!this.isVisible) { return; } this.maybePreventDefault(e); const idx = this.filteredOptions.indexOf(this.focusedItem); if (idx === -1) { this.focusedItem = this.filteredOptions[0]; return; } const nextIdx = idx + dir; const newIdx = nextIdx < 0 ? this.filteredOptions.length - 1 : nextIdx % this.filteredOptions.length; this.focusedItem = this.filteredOptions[newIdx]; } private maybePreventDefault(e?: Event): void { if (e && e.preventDefault) { e.preventDefault(); } } private maybeStopPropagation(e?: Event): void { if (e && e.stopPropagation) { e.stopPropagation(); } } private _escapeRegExp(str: string): RegExp { const regExpStr = str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); return new RegExp(regExpStr, 'i'); } }