Skip to content

Making custom Angular Material multiSelect filterable component #16559

@AurelienVernay

Description

@AurelienVernay

What are you trying to do?

I'm trying to make a custom Angular Material multiSelect filterable component, such as this one :
image

I made a multi-select-search.component with this code :

export interface MultiSelectSearchOption {
    label: string;
    value: any;
}

export interface MultiSelectOverlayData {
    options: MultiSelectSearchOption[];
}

export const MULTI_SELECT_OVERLAY_DATA = new InjectionToken<
    MultiSelectOverlayData
>('MULTI_SELECT_OVERLAY_DATA');



//TEXT INPUT COMPONENTS
@Component({
    selector: 'multiSelectSearch',
    templateUrl: 'multiSelectSearch.component.html',
    styleUrls: ['./multiSelectSearch.component.scss'],
})
export class MultiSelectSearchComponent implements AfterViewInit {
    @Input() list: any[] = [];
    @Input() selection: any;
    /**
     * @param filterKey
     * @description the key of the object to filter out with the text input
     */
    @Input() filterKey: string;
    /**
     * @param labelKey
     * @description the key of the object to be used as label of option
     */
    @Input() labelKey: string;
    @Input() placeholder: string;
    @Output() valueChange = new EventEmitter<any[]>();
    @ViewChild('input', { static: false }) inputViewRef: ElementRef;
    public search = new FormControl('');
    private listOptions: MultiSelectSearchOption[] = [];
    private overlayRef: OverlayRef;
    constructor(private overlay: Overlay) {}

    ngAfterViewInit() {
        if (!this.list.length || !this.labelKey || !this.filterKey) {
            console.error(
                'Component usage require input of list, labelKey, filterKey component'
            );
            throw new Error();
        } else {
            this.search.valueChanges
                .pipe(debounceTime(1000))
                .subscribe(search => {
                    this.listOptions = (!search.length
                        ? this.list
                        : this.list.filter(e =>
                              e[this.filterKey]
                                  .toString()
                                  .toUpperCase()
                                  .startsWith(search.toUpperCase())
                          )
                    ).map(
                        (e: any): MultiSelectSearchOption => ({
                            label: e[this.labelKey],
                            value: e,
                        })
                    );
                    const tokens = new WeakMap();
                    tokens.set(MULTI_SELECT_OVERLAY_DATA, {
                        options: this.listOptions,
                    });
                    this.overlayRef = this.overlay.create({
                        hasBackdrop: false,
                        minWidth: '10vw',
                        minHeight: '10vh',
                        positionStrategy: this.overlay
                            .position()
                            .flexibleConnectedTo(this.inputViewRef)
                            .withPositions([
                                {
                                    offsetX: 0,
                                    offsetY: 0,
                                    originX: 'start',
                                    originY: 'top',
                                    overlayX: 'start',
                                    overlayY: 'top',
                                    panelClass: [],
                                    weight: 1,
                                },
                            ]),
                    });
                    const multiSelectPortal = new ComponentPortal(
                        MultiSelectOverlayComponent,
                        null,
                        tokens
                    );
                    this.overlayRef.attach(multiSelectPortal);
                });
        }
    }
}

// OVERLAY COMPONENT
@Component({
    selector: 'multi-select-overlay',
    template: `
        <mat-card>
            <mat-selection-list #list>
                <mat-list-option
                    *ngFor="let option of listOptions"
                    [value]="option.value"
                    >{{ option.label }}</mat-list-option
                >
            </mat-selection-list>
        </mat-card>
    `,
})
export class MultiSelectOverlayComponent implements AfterViewInit {
    @ViewChild('list', { static: false }) list: MatSelectionList;
    public get listOptions() {
        return this.data.options as MultiSelectSearchOption[];
    }
    constructor(
        @Inject(MULTI_SELECT_OVERLAY_DATA)
        private data: MultiSelectOverlayData
    ) {
        console.log('data', data);
    }

    ngAfterViewInit() {
        this.list.selectionChange.pipe(
            //emit value
            tap(x => console.log(x))
        );
    }
}

Everything seems to be working fine, but when i try to iterate over my data.options element, i get the following error :
image

What troubleshooting steps have you tried?

I put a console.log of the data object passed, i can see that it is indeed an Array :
image

What are you seeing that does not match your expectations?
I don't understand why the component created by the ComponentPortal fail to use ngFor on an Array.?

Reproduction

We can only help if we can reproduce the problem ourselves.

Use StackBlitz to demonstrate what you are trying to do:
Full error and code here :
https://components-issue-55qrra.stackblitz.io/

Steps to reproduce:

  1. Use stackblitz to reproduce error

Environment

  • Angular: 8.1.1
  • CDK/Material: 8.0.2
  • Browser(s): Chrome
  • Operating System Windows,

Metadata

Metadata

Assignees

No one assigned

    Labels

    troubleshootingThis issue is not reporting an issue, but just asking for help

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions