基于 Angular和Material autocomplete组件再封装的可双向绑定key-value的可输入下拉框

发布时间 2023-10-10 10:47:02作者: Grom


GitHub: https://github.com/Xinzheng-Li/AngularCustomerComponent

效果图:为了方便使用,把许多比如ADD的功能去了,可以在使用后自行实现。

 

 

 

 

 

调用:

1     <app-autocomplete-input [menuItems]="autocompleteInputData" [(model)]="autocompleteInputModel" [showAddBtn]="true"
2         [(value)]="autocompleteInputValue" (objectChange)="onChange($event)" (focus)="onFocus($event)"
3         (input)="onInput($event)" (change)="onModelChange($event)" (blur)="onBlur($event)"
4         #autocompleteInput></app-autocomplete-input>

前端:

 1 <div>
 2     <input type="text" matInput [formControl]="myControl"
 3         #autocompleteTrigger="matAutocompleteTrigger" [matAutocomplete]="auto" [placeholder]="placeholder"
 4         #autocompleteInput maxlength={{maxlength}} (focus)="onFocus($event)" (input)="onInput($event)"
 5         (change)="onModelChange($event)" (blur)="onBlur($event)">
 6 
 7     <mat-autocomplete #auto="matAutocomplete" #autocomplete isDisabled="true" (optionSelected)="selectedOption($event)"
 8         [displayWith]="displayFn">
 9         <mat-option *ngFor="let option of filteredOptions | async" [value]="option">
10             {{option.label}}
11         </mat-option>
12         <mat-option *ngIf="loading" [disabled]="true" class="loading">
13             loading...
14         </mat-option>
15         <mat-option *ngIf="showAddBtn&&inputText!=''" [ngClass]="{'addoption-active':addoptionActive}"
16             [disabled]="!addoptionActive" value="(add)" class="addoption">
17             + Add <span>{{ inputText?'"'+inputText+'"':inputText }}</span>
18         </mat-option>
19     </mat-autocomplete>
20 </div>

 

后台:

  1 import { Component, EventEmitter, Input, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core';
  2 import { FormControl } from '@angular/forms';
  3 import { MatAutocomplete, MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete'
  4 import { Observable, Subject, debounceTime, map, startWith } from 'rxjs';
  5 
  6 interface Menu {
  7   value: any;
  8   label: string;
  9 }
 10 
 11 @Component({
 12   selector: 'app-autocomplete-input',
 13   templateUrl: './autocomplete-input.component.html',
 14   styleUrls: ['./autocomplete-input.component.scss']
 15 })
 16 export class AutocompleteInputComponent implements OnInit {
 17   @Input() disabled = false;
 18   @Input() disabledInput = false;
 19   @Input() placeholder = 'autocompleteInput';
 20   @Input() maxlength: number = 50;
 21   @Input() showAddBtn = false;
 22   @Input() loading = false;
 23   _menuItems!: Menu[];
 24   @Input()
 25   get menuItems() {
 26     return this._menuItems;
 27   }
 28   set menuItems(val) {
 29     this._menuItems = val;
 30     if (this.model) {
 31       let mapItem = this.menuItems.find((x) => x.label?.toLowerCase().trim() == this.model?.trim()?.toLowerCase());
 32       if (mapItem) {
 33         this.value = mapItem.value;
 34       } else {
 35         this.model = this.value = '';
 36       }
 37     }
 38     this.myControl.setValue(this.model ?? '');
 39   }
 40 
 41   modelValue: any = { name: '', value: '' };
 42   @Output() objectChange = new EventEmitter();
 43 
 44   //Only for binding model
 45   @Output() modelChange = new EventEmitter();
 46   @Input()
 47   get model() {
 48     return this.modelValue?.name?.trim() ?? '';
 49   }
 50   set model(val) {
 51     this.modelValue.name = this.inputText = val?.trim();
 52     this.modelChange.emit(this.modelValue.name);
 53     this.inputChangeSubject.next(this.modelValue.name);
 54   }
 55 
 56   @Output() valueChange = new EventEmitter();
 57   @Input()
 58   get value() {
 59     return this.modelValue.value;
 60   }
 61   set value(val) {
 62     this.modelValue.value = val;
 63     this.valueChange.emit(this.modelValue.value);
 64   }
 65 
 66   @Output() inputChange = new EventEmitter<any>();
 67 
 68   myControl = new FormControl<string | any>('');
 69   filteredOptions!: Observable<any[]>;
 70   @ViewChild('autocompleteInput') autocompleteInput: any;
 71   @ViewChild('autocomplete') autocomplete!: MatAutocomplete;
 72   @ViewChild(MatAutocompleteTrigger) autocompleteTrigger!: MatAutocompleteTrigger;
 73 
 74   ngOnInit(): void {
 75     this.filteredOptions = this.myControl.valueChanges.pipe(
 76       startWith(''),
 77       map((value) => {
 78         const name = typeof value === 'string' ? value : value.label;
 79         return name ? this._filter(name as string) : this.menuItems.slice();
 80       })
 81     );
 82     this.registEventSubject();
 83     this.inputText = '';
 84   }
 85 
 86   ngOnChanges(changes: SimpleChanges) {
 87     if (changes['menuItems'] && !changes['menuItems'].firstChange) this.loading = false;
 88     if (changes['disabled']) {
 89       this.disabled ? this.myControl.disable() : this.myControl.enable();
 90     }
 91     if (changes['value']) {
 92       let item = this.menuItems.find((x) => x.value == changes['value'].currentValue);
 93       if (item) {
 94         this.value = item?.value ?? '';
 95         this.model = item?.label ?? '';
 96       }
 97     }
 98     if (changes['model']) {
 99       this.inputText = changes['model'].currentValue ?? '';
100       this.myControl.setValue(this.model ?? '');
101     }
102   }
103 
104   private inputChangeSubject = new Subject<string>();
105   private registEventSubject() {
106     this.inputChangeSubject.pipe(debounceTime(100)).subscribe((data: any) => {
107       if (this.loading) return;
108       if (this.autocompleteInput?.nativeElement) this.autocompleteInput.nativeElement.value = this.model;
109       this.objectChange.emit(this.modelValue);
110     });
111   }
112 
113   private _filter(item: any): any[] {
114     const filterValue = item?.toLowerCase()?.trim();
115     return this.menuItems.filter((option) => option.label.toLowerCase().includes(filterValue));
116   }
117 
118   displayFn(e: any) {
119     return e && e.label ? e.label : '';
120   }
121   onFocus(e: any) {
122     if (this.disabledInput) e.target.blur();
123   }
124   @Output() blur = new EventEmitter<any>();
125   onBlur(e: any) {
126     if (e.currentTarget.value != this.model) {
127       this.inputChangeSubject.next(this.model);
128     } else {
129       this.blur.emit(e);
130     }
131   }
132 
133   inputText = '';
134   addoptionActive = false;
135   onInput(e: any) {
136     if (e.currentTarget.value == '') {
137       this.addoptionAction(false);
138       this.myControl.setValue('');
139     } else if (this.menuItems.find((x) => x.label.toLowerCase() == e.currentTarget.value?.trim()?.toLowerCase())) {
140       this.addoptionAction(false);
141     } else {
142       this.addoptionAction(true);
143     }
144     this.inputText = e.currentTarget.value;
145     e.currentTarget.value = this.inputText = e.currentTarget.value.replaceAll(/[`\\~!@#$%^\*_\+={}\[\]\|;"<>\?]/gi, '');
146     if (e.currentTarget.value?.trim() == '') this.myControl.setValue(e.currentTarget.value);
147     this.inputChange.emit(e);
148   }
149 
150   onModelChange(e: any) {
151     if (this.loading) return;
152     if (e.currentTarget.value?.trim()) {
153       let mapItem = this.menuItems.find(
154         (x) => x.label.toLowerCase().trim() == e.currentTarget.value?.trim()?.toLowerCase()
155       );
156       if (mapItem) {
157         this.model = e.currentTarget.value = mapItem.label;
158         this.value = mapItem.value;
159       } else {
160         this.model = e.currentTarget.value;
161         this.value = '';
162       }
163     } else {
164       this.model = this.inputText = e.currentTarget.value;
165       this.value = '';
166     }
167   }
168 
169   selectedOption(e: any) {
170     if (typeof e.option.value === 'string') {
171       this.autocompleteInput.nativeElement.value = this.inputText;
172     } else {
173       let mod = e.option.getLabel() ?? '';
174       let val = e.option.value?.value ?? '';
175       if (val != this.value || mod != this.model) {
176         this.model = mod ?? '';
177         this.value = val ?? '';
178       }
179       if (this.value && this.model) {
180         this.addoptionActive = false;
181       }
182     }
183   }
184 
185   panelAction(type: number) {
186     type == 1 ? this.autocompleteTrigger.openPanel() : this.autocompleteTrigger.closePanel();
187   }
188 
189   addoptionAction(type: boolean) {
190     this.addoptionActive = type;
191   }
192 
193   //It will trigger the change event of the model!
194   clearText() {
195     this.value = this.model = '';
196   }
197 }

实现逻辑:

原Material的autocomplete控件将下拉框和输入内容分为不同的事件,并且无法自定义下拉选项,像例子中的ADD功能,如果使用原控件,则会将“+ Add XXX”显示到输入框中。

另外就是原控件仅支持显示值绑定,因为输入框是没有key的,故,将输入框和下拉框进行二次封装,实现key-value的双向绑定和自定义选项的功能。

必传参数:

[menuItems]: 下拉框的选项,以value-label的形式定义。
[(model)]: 绑定变量后控件会将输入或下拉选项中的显示值赋到此变量,修改此变量也会更改输入框的值。
[(value)]:  绑定变量后控件会将输入或下拉选项中的实际值赋到此变量,如果是输入不在下拉框的中值,则此变量为空,可以根据需要自行实现生成value值。
 
可选参数:
[disabled]: 是否禁用控件
[disabledInput]: 是否禁止输入(下拉框可用)
[placeholder]: 输入框默认显示值
[maxlength]: 输入框最大长度
[showAddBtn]:是否显示添加项按钮(需要自己实现事件,比如生成个key之后push到menuItems中)
[loading]:当数据源为异步加载时,通过控制此变量来显示等待icon
(objectChange): 修改控件值后触发(选中下拉选项、改变或清空输入框值),输出参数为控件key,value, 由于前面已经对key value进行了双向绑定,事件触发不需要再次进行赋值。
其他事件...
 
其他:
106行:防抖函数0.1秒是因为选择项后会触发两次Change事件(selelctoption+modelChange)
145行:控制输入内容的正则表达式
31/139/154行:输入内容与下拉菜单项匹配,匹配规则可以修改这里控制
panelAction: 打开关闭下拉选项框