diff --git a/src/frontend/plugins/select-search/select-search.component.html b/src/frontend/plugins/select-search/select-search.component.html new file mode 100644 index 0000000000000000000000000000000000000000..d3336b3241004622063b19b3b7634312c017cffc --- /dev/null +++ b/src/frontend/plugins/select-search/select-search.component.html @@ -0,0 +1,24 @@ +<mat-form-field class="input-form" floatLabel="never"> + <mat-select [formControl]="formControlSelect" [placeholder]="placeholderLabel" #test> + <input *ngIf="datas.length > 5" matInput class="mat-select-search-input mat-select-search-hidden" /> + + <div *ngIf="datas.length > 5" class="mat-select-search-inner" [ngClass]="{'mat-select-search-inner-multiple': matSelect.multiple}"> + <input matInput id="searchSelectInput" [formControl]="formControlSearch" class="mat-select-search-input" #searchSelectInput (keydown)="_handleKeydown($event)" + (input)="onInputChange($event.target.value)" (blur)="onBlur($event.target.value)" + [placeholder]="lang.filterBy" /> + <button mat-button *ngIf="formControlSearch.value" mat-icon-button aria-label="Clear" (click)="_reset(true)" + class="mat-select-search-clear"> + <mat-icon class="fa fa-times"></mat-icon> + </button> + </div> + + <div *ngIf="noEntriesFoundLabel && value && _options?.length === 0 && datas.length > 5" class="mat-select-search-no-entries-found"> + {{lang.noResult}} + </div> + <mat-option *ngIf="adminMode"></mat-option> + <mat-option *ngFor="let value of filteredDatas | async" [value]="value.id" + [title]="value.title !== undefined ? value.title : value.label" [disabled]="value.disabled" + [class.opt-group]="value.isTitle" [style.color]="value.color" [innerHTML]="value.label"> + </mat-option> + </mat-select> +</mat-form-field> \ No newline at end of file diff --git a/src/frontend/plugins/select-search/select-search.component.scss b/src/frontend/plugins/select-search/select-search.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..7fdc6f47a75ad1989552b15e6a47ef39da74d6b0 --- /dev/null +++ b/src/frontend/plugins/select-search/select-search.component.scss @@ -0,0 +1,48 @@ +@import '../../css/vars.scss'; + + +$mat-menu-side-padding: 16px !default; +$scrollbar-width: 17px; +$clear-button-width: 20px; +$multiple-check-width: 33px; + +.mat-select-search-hidden { + visibility: hidden; +} +.mat-select-search-inner { + position: absolute; + top: 0; + width: calc(100% + #{2 * $mat-menu-side-padding - $scrollbar-width}); + border-bottom: 1px solid #cccccc; + background: white; + z-index: 100; + &.mat-select-search-inner-multiple { + + width: calc(100% + #{2 * $mat-menu-side-padding - $scrollbar-width + $multiple-check-width}); + } +} + +::ng-deep.mat-select-search-panel { + /* allow absolute positioning relative to outer options container */ + transform: none !important; + max-height: 350px; +} + +.mat-select-search-input { + padding: $mat-menu-side-padding; + padding-right: $mat-menu-side-padding + $clear-button-width; + box-sizing: border-box; + +} +.mat-select-search-no-entries-found { + padding: $mat-menu-side-padding; +} +.mat-select-search-clear { + position: absolute; + right: 0; + top: 4px; +} +::ng-deep.cdk-overlay-pane-select-search { + /* correct offsetY so that the selected option is at the position of the select box when opening */ + margin-top: -50px; +} diff --git a/src/frontend/plugins/select-search/select-search.component.ts b/src/frontend/plugins/select-search/select-search.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..81c3ba7aff2bdd57f1992182660b57eb91fb420e --- /dev/null +++ b/src/frontend/plugins/select-search/select-search.component.ts @@ -0,0 +1,314 @@ +import { + AfterViewInit, ChangeDetectorRef, + Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, QueryList, + ViewChild, + Renderer2 +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl } from '@angular/forms'; +import { MatOption, MatSelect } from '@angular/material'; +import { take, takeUntil, startWith, map } from 'rxjs/operators'; +import { Subject, ReplaySubject, Observable } from 'rxjs'; +import { LatinisePipe } from 'ngx-pipes'; +import { LANG } from '../../app/translate.component'; + +@Component({ + selector: 'plugin-select-search', + templateUrl: 'select-search.component.html', + styleUrls: ['select-search.component.scss', '../../app/indexation/indexing-form/indexing-form.component.scss'] +}) +export class PluginSelectSearchComponent implements OnInit, OnDestroy, AfterViewInit, ControlValueAccessor { + lang: any = LANG; + /** Label of the search placeholder */ + @Input() placeholderLabel = this.lang.chooseValue; + + /** Label to be shown when no entries are found. Set to null if no message should be shown. */ + @Input() noEntriesFoundLabel = 'Aucun résultat'; + + @Input('formControlSelect') formControlSelect: FormControl; + + @Input('datas') datas: any; + + + /** Reference to the search input field */ + @ViewChild('searchSelectInput', { read: ElementRef, static: true }) searchSelectInput: ElementRef; + + @ViewChild('test', { static: true }) matSelect: MatSelect; + + /** Current search value */ + get value(): string { + return this._value; + } + private _value: string; + + onChange: Function = (_: any) => { }; + onTouched: Function = (_: any) => { }; + + public filteredDatas: Observable<string[]>; + + public filteredDatasMulti: ReplaySubject<any[]> = new ReplaySubject<any[]>(1); + + /** Reference to the MatSelect options */ + public _options: QueryList<MatOption>; + + /** Previously selected values when using <mat-select [multiple]="true">*/ + private previousSelectedValues: any[]; + + /** Whether the backdrop class has been set */ + private overlayClassSet = false; + + /** Event that emits when the current value changes */ + private change = new EventEmitter<string>(); + + /** Subject that emits when the component has been destroyed. */ + private _onDestroy = new Subject<void>(); + + formControlSearch = new FormControl(); + + + constructor(private latinisePipe: LatinisePipe, private changeDetectorRef: ChangeDetectorRef, private renderer: Renderer2) { + + + } + + ngOnInit() { + // set custom panel class + const panelClass = 'mat-select-search-panel'; + if (this.matSelect.panelClass) { + if (Array.isArray(this.matSelect.panelClass)) { + this.matSelect.panelClass.push(panelClass); + } else if (typeof this.matSelect.panelClass === 'string') { + this.matSelect.panelClass = [this.matSelect.panelClass, panelClass]; + } else if (typeof this.matSelect.panelClass === 'object') { + this.matSelect.panelClass[panelClass] = true; + } + } else { + this.matSelect.panelClass = panelClass; + } + + // when the select dropdown panel is opened or closed + this.matSelect.openedChange + .pipe(takeUntil(this._onDestroy)) + .subscribe((opened) => { + if (opened) { + // focus the search field when opening + this._focus(); + } else { + // clear it when closing + //this._reset(); + this.formControlSearch.reset(); + } + }); + + // set the first item active after the options changed + this.matSelect.openedChange + .pipe(take(1)) + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + this._options = this.matSelect.options; + this._options.changes + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + const keyManager = this.matSelect._keyManager; + if (keyManager && this.matSelect.panelOpen) { + // avoid "expression has been changed" error + setTimeout(() => { + keyManager.setFirstItemActive(); + }); + } + }); + }); + + // detect changes when the input changes + /*this.change + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + this.changeDetectorRef.detectChanges(); + });*/ + + + setTimeout(() => { + this.filteredDatas = this.formControlSearch.valueChanges + .pipe( + startWith(''), + map(value => this._filter(value)) + ); + }, 800); + + + // this.initMultipleHandling(); + + // load the initial bank list + + //this.filteredDatas.next(this.datas.slice()); + + //this.filteredDatasMulti.next(this.datas.slice()); + + // listen for search field value changes + /*this.formControlSelect.valueChanges + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + this.filterDatas(); + });*/ + /*this.formControlSelect.valueChanges + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + this.filterDatasMulti(); + });*/ + } + + ngOnDestroy() { + this._onDestroy.next(); + this._onDestroy.complete(); + } + + ngAfterViewInit() { + if (this.datas.length > 5) { + this.setOverlayClass(); + } + } + + /** + * Handles the key down event with MatSelect. + * Allows e.g. selecting with enter key, navigation with arrow keys, etc. + * @param {KeyboardEvent} event + * @private + */ + _handleKeydown(event: KeyboardEvent) { + if (event.keyCode === 32) { + // do not propagate spaces to MatSelect, as this would select the currently active option + event.stopPropagation(); + } + + } + + + writeValue(value: string) { + const valueChanged = value !== this._value; + if (valueChanged) { + this._value = value; + this.change.emit(value); + } + } + + onInputChange(value: any) { + const valueChanged = value !== this._value; + if (valueChanged) { + this._value = value; + this.onChange(value); + this.change.emit(value); + } + } + + onBlur(value: string) { + this.writeValue(value); + this.onTouched(); + } + + registerOnChange(fn: Function) { + this.onChange = fn; + } + + registerOnTouched(fn: Function) { + this.onTouched = fn; + } + + /** + * Focuses the search input field + * @private + */ + public _focus() { + // save and restore scrollTop of panel, since it will be reset by focus() + // note: this is hacky + const panel = this.matSelect.panel.nativeElement; + const scrollTop = panel.scrollTop; + + // focus + if (this.datas.length > 5) { + this.renderer.selectRootElement('#searchSelectInput').focus(); + } + panel.scrollTop = scrollTop; + } + + /** + * Resets the current search value + * @param {boolean} focus whether to focus after resetting + * @private + */ + public _reset(focus?: boolean) { + + this.formControlSearch.reset(); + + this.renderer.selectRootElement('#searchSelectInput').focus(); + + } + + /** + * Sets the overlay class to correct offsetY + * so that the selected option is at the position of the select box when opening + */ + private setOverlayClass() { + if (this.overlayClassSet) { + return; + } + const overlayClass = 'cdk-overlay-pane-select-search'; + + this.matSelect.overlayDir.attach + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { + // note: this is hacky, but currently there is no better way to do this + this.searchSelectInput.nativeElement.parentElement.parentElement + .parentElement.parentElement.parentElement.classList.add(overlayClass); + }); + + this.overlayClassSet = true; + } + + + /** + * Initializes handling <mat-select [multiple]="true"> + * Note: to improve this code, mat-select should be extended to allow disabling resetting the selection while filtering. + */ + private initMultipleHandling() { + // if <mat-select [multiple]="true"> + // store previously selected values and restore them when they are deselected + // because the option is not available while we are currently filtering + this.matSelect.valueChange + .pipe(takeUntil(this._onDestroy)) + .subscribe((values) => { + if (this.matSelect.multiple) { + let restoreSelectedValues = false; + if (this._value && this._value.length + && this.previousSelectedValues && Array.isArray(this.previousSelectedValues)) { + if (!values || !Array.isArray(values)) { + values = []; + } + const optionValues = this.matSelect.options.map(option => option.value); + this.previousSelectedValues.forEach(previousValue => { + if (values.indexOf(previousValue) === -1 && optionValues.indexOf(previousValue) === -1) { + // if a value that was selected before is deselected and not found in the options, it was deselected + // due to the filtering, so we restore it. + values.push(previousValue); + restoreSelectedValues = true; + } + }); + } + + if (restoreSelectedValues) { + this.matSelect._onChange(values); + } + + this.previousSelectedValues = values; + } + }); + } + + private _filter(value: string): string[] { + if (typeof value === 'string') { + const filterValue = this.latinisePipe.transform(value.toLowerCase()); + return this.datas.filter((option: any) => this.latinisePipe.transform(option['label'].toLowerCase()).includes(filterValue)); + } else { + return this.datas; + } + } + +}