Skip to content

Instantly share code, notes, and snippets.

@Dozorengel
Last active March 20, 2022 17:48
Show Gist options
  • Save Dozorengel/d9a1a2e49cf3832eeb4cddd2d1484810 to your computer and use it in GitHub Desktop.
Save Dozorengel/d9a1a2e49cf3832eeb4cddd2d1484810 to your computer and use it in GitHub Desktop.
Angular Material chip list with autocomplete, multiple selection, drag&drop, async fetching data from api (Angular)
<mat-form-field class="w-full" appearance="outline">
<mat-label>{{ label }}</mat-label>
<mat-chip-list
#chipList
aria-label="Item selection"
cdkDropList
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)"
[cdkDropListDisabled]="!draggable"
>
<mat-chip *ngFor="let item of items" [removable]="true" (removed)="remove(item)" cdkDrag>
{{ item.title }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
<input
#itemInput
[disabled]="!multiple && items.length > 0"
[placeholder]="!multiple && items.length > 0 ? '' : placeholder"
[formControl]="itemCtrl"
[matAutocomplete]="auto"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="enter($event)"
/>
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="select($event)" (opened)="scroll()">
<mat-option *ngFor="let item of autocompleteItems" [value]="item">
{{ item.title }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
import {Component, OnInit, ElementRef, ViewChild, Input, forwardRef} from '@angular/core';
import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop';
import {ENTER} from '@angular/cdk/keycodes';
import {FormControl, ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {MatAutocompleteSelectedEvent, MatAutocomplete, MatAutocompleteTrigger} from '@angular/material/autocomplete';
import {MatChipInputEvent} from '@angular/material/chips';
import {fromEvent, Observable} from 'rxjs';
import {debounceTime, distinctUntilChanged, switchMap, takeUntil, takeWhile} from 'rxjs/operators';
export interface DatalistItem<T> {
title: string;
meta: T;
}
@Component({
selector: 'app-datalist',
templateUrl: './datalist.component.html',
styleUrls: ['./datalist.component.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DatalistComponent),
multi: true,
},
],
})
export class DatalistComponent implements OnInit, ControlValueAccessor {
@ViewChild('itemInput') itemInput: ElementRef<HTMLInputElement>;
@ViewChild('auto') matAutocomplete: MatAutocomplete;
@ViewChild(MatAutocompleteTrigger) matAutocompleteTrigger: MatAutocompleteTrigger;
@Input() label = '';
@Input() placeholder = '';
@Input() multiple = false;
@Input() draggable = false;
@Input() search: (title?: string, reset?: boolean) => Observable<DatalistItem<any>[]>;
separatorKeysCodes: number[] = [ENTER];
itemCtrl = new FormControl();
items: DatalistItem<any>[] = [];
autocompleteItems: DatalistItem<any>[] = [];
private onChange = (value: any) => {};
constructor() {}
ngOnInit(): void {
this.getData();
this.itemCtrl.valueChanges
.pipe(debounceTime(2000), distinctUntilChanged())
.subscribe((value: DatalistItem<any> | string) => {
if (value === null) return;
// @ts-ignore
this.getData(value.title || value, true);
});
}
writeValue(items: DatalistItem<any>[]): void {
this.items = items;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {}
drop(event: CdkDragDrop<DatalistItem<any>[]>) {
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
this.onChange(this.items);
}
enter(event: MatChipInputEvent): void {
const {value} = event;
this.autocompleteItems.find((item) => {
if (item.title === value.trim()) {
this.items.push(item);
this.onChange(this.items);
this.itemInput.nativeElement.value = '';
this.itemCtrl.setValue(null);
if (this.multiple) {
this.getData('', true);
} else {
this.matAutocompleteTrigger.closePanel();
}
}
});
}
select(event: MatAutocompleteSelectedEvent): void {
this.items.push(event.option.value);
this.onChange(this.items);
this.itemInput.nativeElement.value = '';
this.itemCtrl.setValue(null);
if (this.multiple) {
this.getData('', true);
setTimeout(() => {
this.matAutocompleteTrigger.openPanel();
});
}
}
remove(item: DatalistItem<any>): void {
this.items = this.items.filter((it) => it !== item);
this.onChange(this.items);
if (this.multiple) {
this.autocompleteItems.unshift(item);
} else {
this.getData('', true);
}
}
scroll(): void {
// settimeout is necessary as panel doesn't init immediately after event emits
setTimeout(() => {
const panel = this.matAutocomplete.panel.nativeElement;
fromEvent(panel, 'scroll')
.pipe(
debounceTime(50),
takeUntil(this.matAutocompleteTrigger.panelClosingActions),
switchMap(() => {
const atBottom = panel.scrollHeight <= panel.scrollTop + panel.clientHeight + 50;
return atBottom ? this.search(this.itemCtrl.value || '').pipe(takeWhile((arr) => !!arr.length)) : [];
})
)
.subscribe((items) => {
this.autocompleteItems = [...this.autocompleteItems, ...items];
});
});
}
private availableItems(newItems: DatalistItem<any>[]): DatalistItem<any>[] {
return newItems.filter((newItem) => !this.items.find((item) => item.title === newItem.title));
}
private getData(title?: string, reset?: boolean): void {
this.search(title, reset).subscribe((items) => {
this.autocompleteItems = this.availableItems(items);
});
}
}
@Dozorengel
Copy link
Author

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment