Listen to className changes on an element replaced by ng-content

1.9k views Asked by At

I need to listen to class name changes on a element being replaced by the <ng-content>. I have tried many ways and the only way I found for this is by using setInterval which I think is not a good practice. Suppose I will inject an input element inside the <app-child> component

@Component({
  selector: 'app-parent',
  template: `
    <app-child>
     <input type="text">
    </app-child>
  `
})
export class ParentComponent implements OnInit {
  ngOnInit() { }
}

And that I want to do something inside child.component.ts whenever the class attribute of the input change:

@Component({
  selector: 'app-child',
  template: `<ng-content select="input"></ng-content>`
})
export class ChildComponent implements OnInit {
  @ContentChild(HTMLInputElement) input: any;

  ngOnInit() {
    setInterval(() => {
       const { className } = this.input.nativeElement;
       console.log(className);
    }, 500);
  }
}

This approach manages to detect the class change but the problem with setInterval is that that callback will be running on background every 500 milliseconds, is there another way to detect the change?

Note: I've already tried the hook ngAfterContentChecked which is ran automatically after any change detection but inside I don't have access to the latest change on this.input.nativeElement.className as if this function was executed before the value was changed.

2

There are 2 answers

1
Luiz Avila On BEST ANSWER

You can use the MutationObserver Api

Something like this:

  ngAfterContentInit(): void {
    this.changes = new MutationObserver((mutations: MutationRecord[]) => {
      mutations.forEach((mutation: MutationRecord) => {
        // this is called twice because the old class is removed and the new added
        console.log(
          `${mutation.attributeName} changed to ${this.input.nativeElement.classList}`
        );
      });
    });

    this.changes.observe(this.input.nativeElement, {
      attributeFilter: ['class'],
    });
  }

Here is a stackblitz with it running https://stackblitz.com/edit/angular-ivy-tz5q88?file=src%2Fapp%2Fchild.component.ts

0
Alaksandar Jesus Gene On

Below is the code that implements debounceTime. I am not sure

this.callback = this.callback.bind(this); 

is correct way to assign. But this works.

import { Directive, ElementRef } from '@angular/core';
import { isEmpty } from 'lodash';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, first } from 'rxjs';

@Directive({
  selector: '.has-validation'
})
export class HasValidationDirective {
  el: any;
  formElement: any;
  observer: any;
  classNameChanged: Subject<any> = new Subject();
  subscriptions:any = [];

  constructor(el: ElementRef) {
    this.el = el;
  }

  ngOnInit() {
    this.callback = this.callback.bind(this);
    const subscription = this.classNameChanged.pipe(debounceTime(100)).subscribe(() => {
      this.toggleClassname();
    });
    this.subscriptions.push(subscription);
    setTimeout(() => {
      this.toggleClassname();
    })
  }

  ngAfterViewInit() {
    const formElementClassNames = ['form-input', 'form-textarea', 'form-select', 'form-radio', 'form-multiselect', 'form-checkbox'];
    formElementClassNames.forEach((className) => {
      const formElement = this.el.nativeElement.querySelector('.' + className)
      if (!isEmpty(formElement)) {
        this.formElement = formElement;
      }
    })
    if (isEmpty(this.formElement)) {
      const formElementClassNamesJoined = formElementClassNames.join(' (or) ');
      console.error(`class has-validation must have a form element with class name specified as ${formElementClassNamesJoined}`)
      return;
    }
    this.observer = new MutationObserver(this.callback);
    const a = this.observer.observe(this.formElement, { attributeFilter: ['class'] });
  }


  callback(mutationList: any, observer: any) {
    // Use traditional 'for loops' for IE 11
    for (const mutation of mutationList) {
      if (mutation.type === 'childList') {
       // console.log('A child node has been added or removed.');
      }
      else if (mutation.type === 'attributes') {
        // console.log(`The ${mutation.attributeName} attribute was modified.`);
        this.classNameChanged.next(null);

      }
    }
  };

  toggleClassname() {
    const validationClassnames = ['ng-valid',
      'ng-invalid',
      'ng-pending',
      'ng-pristine',
      'ng-dirty',
      'ng-untouched',
      'ng-touched'];

    validationClassnames.forEach((className) => {
      this.el.nativeElement.classList.remove(className);
      if (this.formElement.classList.contains(className)) {
        this.el.nativeElement.classList.add(className);
      }
    })
  }

  ngOnDestroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
    this.subscriptions.forEach((subscription:Subscription)=>{
      subscription.unsubscribe();
    })
  }

}