Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular: Using @ContentChildren to get children inside another component

Tags:

angular

I have created a custom carousel component in Angular 6. Simple usage is like this:

<mpx-carousel>
  <div *mpxCarouselItem>Slide 1</div>
  <div *mpxCarouselItem>Slide 2</div>
  <div *mpxCarouselItem>Slide 3</div>   
</mpx-carousel>

But it also supports nesting, like so:

<mpx-carousel>
  <div *mpxCarouselItem>Slide 1</div>
  <div *mpxCarouselItem>
    <mpx-carousel>
      <div *mpxCarouselItem>Sub-slide A</div>
      <div *mpxCarouselItem>Sub-slide B</div>
    </mpx-carousel>
  </div>
  <div *mpxCarouselItem>Slide 3</div>   
</mpx-carousel>

In the parent CarouselComponent code, I want to determine if there are any child CarouselComponents, and access their properties. So I use @ContentChildren:

@ContentChildren(CarouselComponent, { descendants: true }) 
childCarousels: QueryList<CarouselComponent>;

ngAfterContentInit() {
    console.log(this.childCarousels.length); // 1, BUT it's a reference to itself, not the child
}

In ngAfterContentInit for the parent carousel, I see that @ContentChildren has found 1 child CarouselComponent, which seems good. But closer inspection reveals that it has actually found THIS parent carousel itself, not its child. In order to actually find the child carousel, I have to subscribe to childCarousel.changes:

@ContentChildren(CarouselComponent, { descendants: true }) 
childCarousels: QueryList<CarouselComponent>;

ngAfterContentInit() {
    console.log(this.childCarousels.length); // 1, BUT it's a reference to itself, not the child

    this.childCarousels.changes.subscribe(() => {
        console.log(this.childCarousels.length); // 2, now it includes itself and its child
    });
}

So it's a bit strange that @ContentChildren includes the parent itself, and it's a bit strange that you have to wait for the changes event before detecting the child, but that's an easy enough workaround.

The real trouble begins when the child carousel is found inside another component. The CarouselComponent is a shared component used by a lot of other components in the project, so I could often have used it like this...

<!-- parent carousel -->
<mpx-carousel>
  <mpx-component-A></mpx-component-A>
  <mpx-component-B></mpx-component-B>
</mpx-carousel>

...where <mpx-component-A> is another component that uses an mpx-carousel of its own internally. I still want the parent carousel here to be able to detect carousels used inside the view of mpx-component-A. But when I use the technique above, @ContentChildren does not find the instances of CarouselComponent that are defined within ComponentA. In ngAfterContentInit again childCarousels only contains a single item, which is a reference to the parent itself. And in this case the childCarousels.changes even never even fires, so we don't get the buried child carousels there either:

@ContentChildren(CarouselComponent, { descendants: true }) 
childCarousels: QueryList<CarouselComponent>;

ngAfterContentInit() {
    console.log(this.childCarousels.length); // 1, BUT it's a reference to itself, not the child

    // The changes event never even fires
    this.childCarousels.changes.subscribe(() => {
        console.log(this.childCarousels.length); // N/A
    });
}

I can provide the full code for the CarouselComponent and CarouselItemDirective if that would help. But my question is more generally: is there any way for a parent component to get a reference to descendant components of a certain type (CarouselComponent) contained within another component (ComponentA)?

I may be completely barking up the wrong tree by using @ContentChildren, so I'm open to completely different approaches.

Thanks in advance!

EDIT: Here is a StackBlitz that makes it clearer what I'm trying to do. Notice the console.log() statements in carousel.component.ts and the comments showing expected vs actual output. (Note: in the real world a carousel would animate rotating its items every few seconds. But for simplicity I have removed all that here).

like image 291
Benjamin Avatar asked Oct 04 '18 18:10

Benjamin


2 Answers

This a is a though one, as far as i did my research there is no auto-magical way to query all nested components of type that are inside another component.

ContentChildren:
Does not retrieve elements or directives that are in other components' templates, since a component's template is always a black box to its ancestors.

quote from Angular docs

In the past i had similar use cases and my solution was to create a common interface of all components part of the nesting

export interface CommonQueryInterface {
  commonField: QueryList<any>
}

So the logic was in the most-top (the highest parent component) to have a logic that traverses all child components that have a field commonField inside of them. But I assume this approach is to different from the result that you desired.

So the thing that can be in order to have access to all carousel components inside any carousel in my opinion is to create a carousel service to manage those carousel elements

The service might look something like the following:

@Injectable({ providedIn: "root" })
export class CarouselService {
  carousells = {};

  addCarousel(c: CarouselComponent) {
    if (!this.carousells[c.groupName]) {
      this.carousells[c.groupName] = [];
    }
    this.carousells[c.groupName].push(c);
  }

  getCarousels() {
    return this.carousells;
  }

  clear(c: CarouselComponent) {
    if (c.topLevel) {
      delete this.carousells[c.groupName];
    }
  }
}

My solution suggests that inside your carousel component you add the carousell element itself to the service

export class CarouselComponent implements AfterContentInit, OnDestroy {
  @ContentChildren(CarouselItemDirective) items: QueryList<
    CarouselItemDirective
  >;
  @ContentChildren(CarouselComponent, { descendants: true })
  childCarousels: QueryList<CarouselComponent>;
  @Input() name;
  @Input() groupName;
  @Input() topLevel = false;

  @ViewChildren(CarouselComponent) childCarousels2: QueryList<
    CarouselComponent
  >;

  constructor(private cs: CarouselService) {}

  getActiveCarousels() {
    return this.cs;
  }

  ngAfterContentInit() {
    if (this.name) {
      this.cs.addCarousel(this);
    }
  }

  ngOnDestroy(){
      this.cs.clear(this)
  }

  queryCarousells() {
    return this.cs.getCarousels();
  }
}

After doing so you will be able to access the active(on-screen) carousels whenever you feel like it. The downside of this approach is that you need to have beside the name @Input one more groupName @Input. This is needed in my implementation so that we can distinguish different carousel components in the same view.

Here is a basic implementation of the concept in StackBlitz

The benefits of this implementation is that by having access to all carousels you can add custom carousel related logic inside the carousel component itself and it will be locked away from the consumer of the component. For example such logic might be to restart all carousels inside a single carousel group.

like image 85
Християн Христов Avatar answered Oct 04 '22 16:10

Християн Христов


app.componet

<!--see that hello has a new @Input
    Use a "reference variable"
-->
<hello id="myId" name="{{ name }}" [component]="other">
  <div #my></div>
  <div #my></div>
  <other #other>
  </other>
</hello>
<p>

Hello component

  @Input() name: string;
  @Input() component: any;  //<---component, really is "other"
  @ContentChildren('my') divs:QueryList<any>
  constructor(private el:ElementRef){}
  ngOnInit()
  {
    console.log(this.el.nativeElement.getAttribute('id'));
  }
  ngAfterViewInit()
  {
        console.log(this.divs.length);
        console.log(this.component.divs.length)

  }

other component

@Component({
  selector: 'other',
  template: `
  <div>
  <div #my></div>
      <div #my></div>
      <div #my></div>
  </div>`
})
export class OtherComponent{
  @Input() name: string;
  //I use ViewChildren, not ContentChildren because is not in a <ng-content>
  @ViewChildren('my') divs:QueryList<any>  //<--has his own "divs"
}
like image 21
Eliseo Avatar answered Oct 04 '22 18:10

Eliseo