4

My goal:

I'm trying to build a reusable mat-form-field with a clear button.

How I tried achieving my goal:

I created a "mat-clearable-input" component and used it like this:

<mat-clearable-input>
        <mat-label>Put a Number here pls</mat-label>
        <input matInput formControlName="number_form_control">
    </mat-clearable-input>

mat-clearable-input.component.html

<mat-form-field>
    <ng-content></ng-content>
</mat-form-field>

Expected result:

the ng-content tag takes the label and the input and puts them inside the mat-form-field tag.

Actual result:

Error: mat-form-field must contain a MatFormFieldControl.
    at getMatFormFieldMissingControlError (form-field.js:226)
    at MatFormField._validateControlChild (form-field.js:688)
    at MatFormField.ngAfterContentChecked (form-field.js:558)
    at callHook (core.js:2926)
    at callHooks (core.js:2892)
    at executeInitAndCheckHooks (core.js:2844)
    at refreshView (core.js:7239)
    at refreshComponent (core.js:8335)
    at refreshChildComponents (core.js:6991)
    at refreshView (core.js:7248)

It looks like I'm missing something and I'm not using correctly the ng-content tag.

I wasn't able to locate the documentation for the ng-content tag on the angular website.

Thank you for any help.

EDIT AFTER ANSWER BELOW

So I tried this suggested method:

export class MatClearableInputComponent implements OnInit {
  @ContentChild(MatFormFieldControl) _control: MatFormFieldControl<any>;
  @ViewChild(MatFormField) _matFormField: MatFormField;
  // see https://stackoverflow.com/questions/63898533/angular-ng-content-not-working-with-mat-form-field/
  ngOnInit() {
    this._matFormField._control = this._control;
  }

}

unfortunately, when I try to use this in a form it still fails with the error "Error: mat-form-field must contain a MatFormFieldControl."

Code where i try to use this component in a form:

<mat-clearable-input>
    <mat-label>Numero incarico</mat-label>
    <buffered-input matInput formControlName="numero"></buffered-input>
</mat-clearable-input>

Repro on stackblitz: https://stackblitz.com/edit/angular-material-starter-xypjc5?file=app/clearable-form-field/clearable-form-field.component.html

notice how the mat-form-field features aren't working (no outline, no floating label), also open the console and you'll see the error Error: mat-form-field must contain a MatFormFieldControl.

EDIT AFTER OPTION 2 WAS POSTED

I tried doing this:

<mat-form-field>
  <input matInput hidden>
  <ng-content></ng-content>
</mat-form-field>

It works, but then when i added a mat-label to my form field, like this:

<mat-clearable-input>
        <mat-label>Numero incarico</mat-label>
        <buffered-input matInput formControlName="numero"></buffered-input>
    </mat-clearable-input>

the label is never floating and it's just staying there as a normal span the whole time.

So i tried assigning to the this._matFormField._control._label the content child with the label but that didn't work because _label is private and there is no setter for it.

It looks like I'm out of luck and this can't be done in Angular without going through a lot of effort.

If you have any further ideas feel free to fork the stackblitz and try!

Edit after @evilstiefel answer

the solution works only for native <input matInput>. When I try replacing that with my custom input component, it doesn't work anymore.

Working setup:

<mat-form-field appClearable>
    <mat-label>ID incarico</mat-label>
    <input matInput formControlName="id">
</mat-form-field>

Same setup but with my custom "buffered-input" component (not working :( )

<mat-form-field appClearable>
    <mat-label>ID incarico</mat-label>
    <buffered-input matInput formControlName="id"></buffered-input>
</mat-form-field>

The console logs this error when I click on the clear button:

TypeError: Cannot read property 'ngControl' of undefined
    at ClearableDirective.clear (clearable.directive.ts:33)
    at ClearButtonComponent.clearHost (clearable.directive.ts:55)
    at ClearButtonComponent_Template_button_click_0_listener (clearable.directive.ts:47)
    at executeListenerWithErrorHandling (core.js:14293)
    at wrapListenerIn_markDirtyAndPreventDefault (core.js:14328)
    at HTMLButtonElement.<anonymous> (platform-browser.js:582)
    at ZoneDelegate.invokeTask (zone-evergreen.js:399)
    at Object.onInvokeTask (core.js:27126)
    at ZoneDelegate.invokeTask (zone-evergreen.js:398)
    at Zone.runTask (zone-evergreen.js:167)
5
  • 2
    If you want to use a component inside a mat-form-field (in this case, to put mat-clearable-input inside it) it must implement MatFormFieldControl interface. Take a look at the docs to see how to do it. Commented Sep 15, 2020 at 10:02
  • @julianobrasil thanks, but that's not the problem: in the code snippet in the question, I'm using an input tag with the matInput directive, so that should work inside mat-form-field. I'm not creating custom components. Also keep in mind that this code works if I don't use ng-content and I just put the input tag there by hand Commented Sep 15, 2020 at 10:37
  • Maybe a stackblitz would be helpful Commented Sep 24, 2020 at 12:40
  • one question: what should it look like? Commented Sep 30, 2020 at 15:34
  • @AndreEirico like the first example of this page material.angular.io/components/input/examples Commented Sep 30, 2020 at 19:35

3 Answers 3

1
+50

Another solution is using a directive to implement the behaviour.

import {
  AfterViewInit,
  Component,
  ComponentFactory,
  ComponentFactoryResolver,
  ContentChild,
  Directive,
  Injector,
  Input,
  Optional,
  SkipSelf,
  TemplateRef,
  ViewContainerRef,
} from '@angular/core';
import { MatFormFieldControl } from '@angular/material/form-field';


@Directive({
  selector: '[appClearable]'
})
export class ClearableDirective implements AfterViewInit {

  @ContentChild(MatFormFieldControl) matInput: MatFormFieldControl<any>;
  @Input() appClearable: TemplateRef<any>;
  private factory: ComponentFactory<ClearButtonComponent>;

  constructor(
    private vcr: ViewContainerRef,
    resolver: ComponentFactoryResolver,
    private injector: Injector,
  ) {
    this.factory = resolver.resolveComponentFactory(ClearButtonComponent);
  }

  ngAfterViewInit(): void {
    if (this.appClearable) {
      this.vcr.createEmbeddedView(this.appClearable);
    } else {
      this.vcr.createComponent(this.factory, undefined, this.injector);
    }
  }

  /**
   * This is used to clear the formControl oder HTMLInputElement
   */
  clear(): void {
    if (this.matInput.ngControl) {
      this.matInput.ngControl.control.reset();
    } else {
      this.matInput.value = '';
    }
  }
}

/**
 * This is the markup/component for the clear-button that is shown to the user.
 */
@Component({
  selector: 'app-clear-button',
  template: `
  <button (click)="clearHost()">Clear</button>
  `
})
export class ClearButtonComponent {
  constructor(@Optional() @SkipSelf() private clearDirective: ClearableDirective) { }

  clearHost(): void {
    if (this.clearDirective) {
      this.clearDirective.clear();
    }
  }
}

This creates a directive called appClearable and an optional Component for a fallback-layout. Make sure to add the component and the directive to the declarations-array of your module. You can either specify a template to use for providing the user-interface or just use the ClearButtonComponent as a one-size-fits-all solution. The markup looks like this:

<!-- Use it with a template reference -->
<mat-form-field [appClearable]="clearableTmpl">
  <input type="text" matInput [formControl]="exampleInput">
</mat-form-field>

<!-- use it without a template reference -->
<mat-form-field appClearable>
  <input type="text" matInput [formControl]="exampleInput2">
</mat-form-field>

<ng-template #clearableTmpl>
  <button (click)="exampleInput.reset()">Marked-Up reference template</button>
</ng-template>

This works with and without a ngControl/FormControl, but you might need to adjust it to your use-case.

Sign up to request clarification or add additional context in comments.

4 Comments

This is working but only when i use it like this. <mat-form-field appClearable> <mat-label>ID incarico</mat-label> <input matInput formControlName="id"> </mat-form-field> If I try using it on a custom form control, it doesn't work. I'll do another edit to the question showing the problem
Ah. It will if you change the type of the @ContentChild() from MatInput to MatFormFieldControl. My version initially only worked with native elements like input or textarea. I've updated my answer accordingly.
Thanks, that worked. Do you also happen to also know how to put the clear button HTML INSIDE of the mat form field instead of as a sibling? I need the button to be a child of the mat form field so that I can position it relative to the mat form field.
Maybe take a look at stackoverflow.com/questions/38093727 for a strategy. Unfortunately, the viewContainerRef for the ContentChild cannot be called the same way as in my example. You can get it to work by accessing native elements, but I would avoid that if possible.
1

Update:

Option 1 does not work for new angular versions because @ViewChild() returns undefined in ngOnInit() hook. Another hack is to use a dummy MatFormFieldControl -

Option 2

<mat-form-field>
  <input matInput hidden>
  <ng-content></ng-content>
</mat-form-field>

Edit:

That error is thrown because MatFormField component queries the child content using @ContentChild(MatFormFieldControl) which does not work if you use nested ng-content (MatFormField also uses content projection).

Option 1 (deprecated)

Below is how you can make it work -

@Component({
  selector: 'mat-clearable-input',
  template: `
    <mat-form-field>
      <ng-content></ng-content>
    </mat-form-field>
  `
})
export class FieldComponent implements OnInit { 
    @ContentChild(MatFormFieldControl) _control: MatFormFieldControl<any>;
    @ViewChild(MatFormField) _matFormField: MatFormField;

    ngOnInit() {
        this._matFormField._control = this._control;
    }
}

Please checkout this stackBlitz. Also, there is this issue created in github already.

13 Comments

Thank you, it looks like this is working, just one more question: why / how does this work?
That's because angular material could not query MatFormFieldControl via ng-content by itself during AfterContentInit (i'm not sure why). Take a look at the form-field.ts file from angular material where the error is thrown and see the call hierarchy. In the above solution, we are doing the same thing explicitly which MatFormField component does to get MatFormFieldControl - github.com/angular/components/blob/….
One thing to note is that MatFormField component also uses content projection and maybe it's not able to query MatFormFieldControl because of multiple content projection down the line. I'm just guessing - github.com/angular/components/blob/…
I think I'm right. @ContentChild() does not work for nested content projections. Since angular material is also using projection, it fails to find the MatFormFieldControl if you also use content projection - stackoverflow.com/questions/38060342/…
Perfect answer, maybe you could add that to the answer. It looks like it's something applicable to a lot of other cases. Thank you!
|
1

As of Angular 14 in 2022, the issue for the MatFormField has been closed against angular/angular#37319, without a solution. If you need this to work, the following seems to be the best solution that's possible now to use <ng-content> with mat-form-field:

@Component({
  selector: 'my-input-wrapper',
  template: `
    <mat-form-field appearance="standard">
      <ng-content></ng-content>
      <!-- make sure this is destroyed so all bindings/subscriptions are removed-->
      <input *ngIf="isBeforeViewInit$$ | async" hidden matInput />
    </mat-form-field>
  `,
});
class MyInputWrapper implement AfterViewInit {
  isBeforeViewInit$$ = new BehaviorSubject(true);
  @ContentChild(MatFormFieldControl) matFormControl: MatFormFieldControl<unknown>;
  @ViewChild(MatFormField) matFormField: MatFormField;

  ngAfterViewInit() {
    // replace the reference to the dummy control
    this.matFormField._control = this.matFormControl;
    // force the form field to rebind everything to the actual control
    this.matFormField.ngAfterContentInit();
    this.isBeforeViewInit$$.next(false);
  }
}

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.