Create an Accordion component in Angular: parents-children communication

Home/code/angular/Create an Accordion component in Angular: parents-children communication

Create an Accordion component in Angular: parents-children communication

How a parent component can communicate and handle its children by using the transclusion and @ContentChildren

<accordion>
  <panel title="one">... any ...</panel>
  <panel title="two">... any ...</panel>
  <panel title="three">... any ...</panel>
</accordion>
LIVE DEMO

INTRODUCTION

As Angular developer you should well know the difference between stateless vs stateful components and how transclusion, Input and Output decorators work.

If not, you should read this interesting article by Todd Motto about this topic.

STATELESS COMPONENTS

First of all, we need to create a stateless collapsable panel. It will be an important part of our Accordion but can be also work as standalone component, as you’ll see below:

<panel title="Profile Form" [opened]="isOpen" (toggle)="isOpen = !isOpen">
  <input type="text">
  ... any element or component ...
</panel>

Why we don’t create a stateful panel component instead?

It would be very comfortable to use since it would handle the behaviors from within the component itself:

<panel title="Profile Form">
  <input type="text">
  ... any element or component ...
</panel>

Stateless components often need a stateful parent to manage their state instead.
Although it may seem a limit, this represents one of the key-points to create presentational components in Flux / Redux / or in any other FE architectural pattern.

Furthermore, since we’ll use these panels as children of the accordion, this approach will be necessary in order to manage them.

PANEL COMPONENT

Here the completed source code of the panel component:

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'panel',
  template: `
  <div class="panel panel-info">
    <div class="panel-heading" (click)="toggle.emit()">
      {{title}}
    </div>
    <div class="panel-body" *ngIf="opened">
      <ng-content></ng-content>
    </div>
  <div>
  `
})
export class PanelComponent {
  @Input() opened = false;
  @Input() title: string;
  @Output() toggle: EventEmitter<any> = new EventEmitter<any>();
}

KEY POINTS

  • The title input property and the ng-content component are used to transfer content from the parent to the component.
    The first one is used to pass a string value as attribute while the second one can be used to transfer any kind of content (aka transclusion).

  • The panel accepts an opened Input property too, that can be used to display or hide the body of the panel
  • When the header of the panel is clicked, a toggle “Output” event is emitted
  • When toggle event is emitted, the parent should perform a logical negation to the opened property

Of course, you could also apply an *ngFor directive to a panel component and populate each cloned template with dynamic data:


<panel *ngFor="let c of cities" 
    [title]="c.name" [opened]="c.opened" 
    (toggle)="c.opened = !c.opened"> {{c.desc}} </panel>

and the result will be very similar to an accordion component:

Tip for React devs

You can think @Input decorators as React props and transclusion as the special children property.

ACCORDION

INTRODUCTION

Our goal is the creation of an Accordion component whose children are represented by the Panel components:

<accordion>
  <panel title="Rome">... any ...</panel>
  <panel title="New York">... any ...</panel>
  <panel *ngFor="let c of cities" [title]="c.name">{{c.desc}}</panel>
</accordion>

It would be very cool but we cannot do it.

In fact, since our panel components are stateless, we need to pass an opened value to each one, listening each toggle event in order to toggle the state of the panel when an header is clicked.

Of course, you could manually do it for each panel:

<accordion>
  <panel title="Rome" [opened]="isOpen" (toggle)="isOpen = !isOpen">...</panel>
  <panel title="Trieste" [opened]="isOpen2" (toggle)="isOpen2 = !isOpen2">...</panel>
  <panel title="Milano" [opened]="isOpen3" (toggle)="isOpen3 = !isOpen3">...</panel>
</accordion>

or by using a data-driven approach as well:

<accordion>
  <panel *ngFor="let c of cities" 
    [title]="c.name" [opened]="c.opened" 
    (toggle)="c.opened = !c.opened">{{c.desc}}</panel>
</accordion>

Anyway, in both scenarios we have to manually handle inputs / outputs and it’s not very comfortable.
When I need to reuse a component I want to spend as little time as possible to remember how to add it to my template.
So how can accomplish this task in order to be used in the following way?

<accordion>
  <panel title="Rome">... any ...</panel>
  <panel title="New York">... any ...</panel>
  <panel *ngFor="let c of cities" [title]="c.name">{{c.desc}}</panel>
</accordion>

ACCORDION COMPONENT

First, the accordion component should orchestrate Inputs/Outputs of any child and it should be done “behind the scenes” to enhance the “developer experience”.
Furthermore, we have other two minor goals too:

  • automatically open the first panel when an accordion is instantiated
  • close the active panel when a new one is toggled

So, following the completed source code of the accordion and I have published a working demo on WebPackBin:

import { Component, ContentChildren, QueryList, AfterContentInit } from '@angular/core';
import { PanelComponent } from './panel.component';

@Component({
  selector: 'accordion',
  template: '<ng-content></ng-content>'
})
export class AccordionComponent  implements AfterContentInit {
  @ContentChildren(PanelComponent) panels: QueryList<PanelComponent>;

  ngAfterContentInit() {
    // Open the first panel
    this.panels.toArray()[0].opened = true;
    // Loop through all panels
    this.panels.toArray().forEach((panel: QueryList<PanelComponent>) => {
      // subscribe panel toggle event
      panel.toggle.subscribe(() => {
        // Open the panel
        this.openPanel(panel);
      });
    });
  }

  openPanel(panel: PanelComponent) {
    // close all panels
    this.panels.toArray().forEach(p => p.opened = false);
    // open the selected panel
    panel.opened = true;
  }
}

KEY POINTS

  • ContentChildren is a special decorator you can use to select children by using a rule. In this scenario our rule is simply “select all the children whose class name is PanelComponent“. You can think to it as a special querySelectorAll for your Angular components
  • The HTML template simply contains an ng-content component to receive and display dynamic content into it (in this scenario our panels)
  • We need to wait the ngAfterContentInit lifecycle hook to be sure all children are ready and available
  • Now we can open the first panel, setting its opened property to true, and loop through all panels to manually subscribe each toggle event. So, when a panel is toggled, the openPanel will be automatically invoked.
  • The openPanel method closes all panels and opens the current one.
  • In a real world scenario you should unsubscribe all events when the ngOnDestroy hook is invoked.

QueryList is an unmodifiable list of items that Angular keeps up to date when the state of the application changes so any time a child element is added, removed, or moved the query list will be updated. In fact QueryList provides a changes property you can subscribe to get notified when something happens to the list.

Tip for React devs

@ContentChildren is a special way to get references to Angular classes and components, very similar to React refs but more powerful

2017-07-14T11:10:41+00:00 luglio 10th, 2017|angular|

Leave A Comment