16
votes

I'm trying to create my own custom angular material component that would be able to work with a mat-form-field control.

Added to that, I'd like the control to use the mat-autocomplete directive.

My aim is simply to create a better-looking mat-autocomplete component with an integrated clear-button and custom css arrow like the following image. I have succesfully obtained it by using the standard component and added what I wanted but now I want to export it into a generic component.

material component aim

I'm using the official angular material documentation to create my own form field control and also another SO post about it which already helped me a lot :

I am currently facing several problems which I believe are linked :

  • My form is not valid even when the value is selected correctly.
  • The placeholder is not setting itself correctly after an option is selected.
  • The auto-complete filter option doesn't work at all
  • The focus does not trigger correctly if I don't click on the input specifically.

I believe my first three issues are caused by the auto-complete value that is not linked to my reactive form correctly.


Here is a direct link to a personnal public repository with the project (since the issue is a bit big to be displayed here) : Git Repository : https://github.com/Tenmak/material.


Basically, the idea is to transform this :

  <mat-form-field>
    <div fxLayout="row">
      <input matInput placeholder="Thématique" [matAutocomplete]="thematicAutoComplete" formControlName="thematique" tabindex="1">

      <div class="mat-select-arrow-wrapper">
        <div class="mat-select-arrow" [ngClass]="{'mat-select-arrow-down': !thematicAutoComplete.isOpen, 'mat-select-arrow-up': thematicAutoComplete.isOpen}"></div>
      </div>
    </div>
    <button mat-button *ngIf="formDossier.get('thematique').value" matSuffix mat-icon-button aria-label="Clear" (click)="formDossier.get('thematique').setValue('')">
      <mat-icon>close</mat-icon>
    </button>

    <mat-hint class="material-hint-error" *ngIf="!formDossier.get('thematique').hasError('required') && formDossier.get('thematique').touched && formDossier.get('thematique').hasError('thematiqueNotFound')">
      <strong>
        Veuillez sélectionner un des choix parmi les options possibles.
      </strong>
    </mat-hint>
  </mat-form-field>

  <mat-autocomplete #thematicAutoComplete="matAutocomplete" [displayWith]="displayThematique">
    <mat-option *ngFor="let thematique of filteredThematiques | async" [value]="thematique">
      <span> {{thematique.code}} </span>
      <span> - </span>
      <span> {{thematique.libelle}} </span>
    </mat-option>
  </mat-autocomplete>

into this :

  <mat-form-field>
    <siga-auto-complete placeholder="Thématique" [tabIndex]="1" [autoCompleteControl]="thematicAutoComplete" formControlName="thematique">
    </siga-auto-complete>

    <mat-hint class="material-hint-error" *ngIf="!formDossier.get('thematique').hasError('required') && formDossier.get('thematique').touched && formDossier.get('thematique').hasError('thematiqueNotFound')">
      <strong>
        Veuillez sélectionner un des choix parmi les options possibles.
      </strong>
    </mat-hint>
  </mat-form-field>

  <mat-autocomplete #thematicAutoComplete="matAutocomplete" [displayWith]="displayThematique">
    <mat-option *ngFor="let thematique of filteredThematiques | async" [value]="thematique">
      <span> {{thematique.code}} </span>
      <span> - </span>
      <span> {{thematique.libelle}} </span>
    </mat-option>
  </mat-autocomplete>

I'm currently working in the "dossiers" folder which displays my initial reactive form. And I'm using my custom component autocomplete.component.ts inside this form directly to replace the first field.

Here is my attempt at the code of the generic component (simplified):

class AutoCompleteInput {
    constructor(public testValue: string) {
    }
}

@Component({
    selector: 'siga-auto-complete',
    templateUrl: './autocomplete.component.html',
    styleUrls: ['./autocomplete.component.scss'],
    providers: [
        {
            provide: MatFormFieldControl,
            useExisting: SigaAutoCompleteComponent
        },
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => SigaAutoCompleteComponent),
            multi: true
        }
    ],
})
export class SigaAutoCompleteComponent implements MatFormFieldControl<AutoCompleteInput>, AfterViewInit, OnDestroy, ControlValueAccessor {
    ...
    parts: FormGroup;
    ngControl = null;

    ...

    @Input()
    get value(): AutoCompleteInput | null {
        const n = this.parts.value as AutoCompleteInput;
        if (n.testValue) {
            return new AutoCompleteInput(n.testValue);
        }
        return null;
    }
    set value(value: AutoCompleteInput | null) {
        // Should set the value in the form through this.writeValue() ??
        console.log(value);
        this.writeValue(value.testValue);
        this.stateChanges.next();
    }

    @Input()
    set formControlName(formName) {
        this._formControlName = formName;
    }
    private _formControlName: string;

    // ADDITIONNAL
    @Input() autoCompleteControl: MatAutocomplete;
    @Input() tabIndex: string;

    private subs: Subscription[] = [];

    constructor(fb: FormBuilder, private fm: FocusMonitor, private elRef: ElementRef) {
        this.subs.push(
            fm.monitor(elRef.nativeElement, true).subscribe((origin) => {
                this.focused = !!origin;
                this.stateChanges.next();
            })
        );

        this.parts = fb.group({
            'singleValue': '',
        });

        this.subs.push(this.parts.valueChanges.subscribe((value: string) => {
            this.propagateChange(value);
        }));
    }

    ngAfterViewInit() {
        // Wrong approach but some idea ?
        console.log(this.autoCompleteControl);
        this.autoCompleteControl.optionSelected.subscribe((event: MatAutocompleteSelectedEvent) => {
            console.log(event.option.value);
            this.value = event.option.value;
        })
    }

    ngOnDestroy() {
        this.stateChanges.complete();
        this.subs.forEach(s => s.unsubscribe());
        this.fm.stopMonitoring(this.elRef.nativeElement);
    }

    ...

    // CONTROL VALUE ACCESSOR
    private propagateChange = (_: any) => { };

    public writeValue(a: string) {
        console.log('wtf');

        if (a && a !== '') {
            console.log('value => ', a);
            this.parts.setValue({
                'singleValue': a
            });
        }
    }
    public registerOnChange(fn: any) {
        this.propagateChange = fn;
    }

    public registerOnTouched(fn: any): void {
        return;
    }

    public setDisabledState?(isDisabled: boolean): void {
        this.disabled = isDisabled;
    }
}
1

1 Answers

16
votes

Finally solved !!!

enter image description here

  1. The problem here is when creating the input field in the child[SigaAutoCompleteComponent], the parent has to know about the value filled in the child [CreateDossierComponent], that part is missing that is the reason it cant turn to valid as it thinks that input field is not touched [stays invalid] -- solved by emitting the value to parent then manipulating the form control as needed.
  2. Spiltting up the mat-form-field and input caused the issue -- solved by moving mat-form-field element to child , other code stays intact and this solves both the placeholder overlapping and clicking on arrow icon to display
  3. This can be done-[one way of doing by redesigning] , by injecting the service into child component and doing the autocomplete feature over there [i haven't implemented this, but this will work as it just copy of department field]

    in create-doiser.component.html

          <!-- </div> -->
          <mat-autocomplete #thematicAutoComplete="matAutocomplete" [displayWith]="displayThematique">
            <mat-option *ngFor="let thematique of filteredThematiques | async" [value]="thematique">
              <span> {{thematique.code}} </span>
              <span> - </span>
              <span> {{thematique.libelle}} </span>
            </mat-option>
          </mat-autocomplete>
    

    in autocomplete.component.html

    <mat-form-field style="display:block;transition:none ">
    <div fxLayout="row">
      <input  matInput   placeholder="Thématique" [matAutocomplete]="autoCompleteControl" (optionSelected)="test($event)" tabindex="{{tabIndex}}">
      <div class="mat-select-arrow-wrapper" (click)="focus()">
        <div class="mat-select-arrow" [ngClass]="{'mat-select-arrow-down': !autoCompleteControl.isOpen, 'mat-select-arrow-up': autoCompleteControl.isOpen}"></div>
      </div>
    </div>
    
    </mat-form-field>
    

    in autocomplete.component.ts

    in set value emit the value to parent
    this.em.emit(value);
    

    create-dosier.component.ts

      this.thematique = new FormControl( ['', [Validators.required, this.thematiqueValidator]]
    
        ); 
    
    this.formDossier.addControl('thematique',this.thematique);
    call(event){
    
        this.thematique.setValue(event);
        this.thematique.validator=this.thematiqueValidator();
        this.thematique.markAsTouched();
        this.thematique.markAsDirty();
    
      }
    }
    

    this will resolve all the issues, Please let me know if you want me to push to github .. Hope this helps !!!! Thanks !!

    UPDATE: Auto complete , mat-hint now everything is working..

    I understand that you don't want input and mat-form-field to be together

    but if it is just for mat-hint to show dynamically[which is depending on formcontrol values], we can pass the formcontrol from parent to child which even eliminates the necessity of emitting the input value from child to parent setting the value in parent component and [mat-hint field stays in the parent component itself]

    enter image description here