Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Emitting output from ng-content to parent

Tags:

angular

I'm attempting to create a wrapper for the Bootstrap split-style dropdown buttons using a button component and a dropdown component. I need to emit an output on click and on document:click from ng-content (which is a <button perf-btn>) to the parent DropdownComponent.

There are several similar questions, but none seem to fit my use case.

usage (app.component.html)

<perf-drop [data]="items">
  <button perf-btn>Default Dropdown Button</button> // click doesn't open dropdown
  <button perf-btn dropdown="true"></button>        // click opens dropdown
</perf-drop>

dropdown.component.html

<ng-content select="[perf-btn]" (notify)='onNotify($event)')></ng-content>
<ul class="dropdown-menu">
  <template ngFor let-item [ngForOf]="data">
    <li *ngIf="item.separator" role="separator" class="divider"></li>
    <li *ngIf="!item.separator" [class.disabled]="item.disabled">
      <a [routerLink]="item.path" [ngClass]="getItemColor(item.color)">
        {{item.label}}
      </a>
    </li>
  </template>
</ul>

dropdown.component.ts

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

@Component({
  selector: 'perf-drop',
  host: {
    '[attr.disabled]': 'disabled',
    '[class.open]': 'isOpen'
  },
  templateUrl: 'dropdown.component.html',
  styleUrls: ['dropdown.component.scss']
})
export class DropdownComponent {
  private _data: any[] = [];
  private _isOpen: boolean = false;

  @Input()
  get isOpen() { return this._isOpen; }
  set isOpen(value: boolean) { this._isOpen = value ? true : null; }

  @Input()
  get data(): any[] { return this._data; }
  set data(value: any[]) {
    this._data = value;
  }

  constructor(private _elementRef: ElementRef) { }

  private toggle(): void {
    this._isOpen = !this._isOpen;
  }

  private close(event): void {
    if (!this._elementRef.nativeElement.contains(event.target) && this._isOpen)
      this._isOpen = false;
  }

  private getItemColor(color) {
    if (color) return `text--${color}`;
  }
}

btn.component.ts

import { Component, ViewEncapsulation, Input, HostBinding, ChangeDetectionStrategy,
  ElementRef, Renderer, EventEmitter, Output } from '@angular/core';

@Component({
  selector: 'button[perf-btn], input[perf-btn], a[perf-btn], div[perf-btn], perf-btn',
  host: {
    [snip conditional classes],
    "(click)": "_toggle()",
    "(document:click)": "_close($event)"
  },
  templateUrl: './btn.component.html',
  styleUrls: ['./btn.component.scss']
})
export class BtnComponent {
  [snip irrelevant fields]
  private _dropdown: boolean;
  private _state: boolean = false;

  @Input()
  get dropdown() { return this._dropdown; }
  set dropdown(value: boolean) { this._dropdown = value ? true : null; }

  get state() { return this._state; }
  set state(value: boolean) { this._state = value ? true : null; }

  [snip irrelevant getters/setters]

  @Output() notify: EventEmitter<boolean> = new EventEmitter<boolean>();

  constructor(private _elementRef: ElementRef, private _renderer: Renderer) { }

  _toggle() {
    console.log("notifying " + this._state);
    this._state = !this._state;
    this.notify.emit(this._state);
  }

  _close(event) {
    if (!this._elementRef.nativeElement.contains(event.target) && this._state) {
      this._state = false;
      this.notify.emit(this._state);
    }
  }

  [snip irrelevant functions]
}

btn.component.html

<ng-content></ng-content>
<span class="caret" *ngIf="dropdown"></span>

dropdown.directive.ts

import { Directive } from '@angular/core';
import { BtnDirective } from './btn.directive';

@Directive({
  selector: `button[perf-drop], button[perf-drop], a[perf-drop], input[perf-drop],
            div[perf-drop], perf-drop`,
  host: {
    '[class.btn-group]': 'true',
    '[attr.disabled]': '[disabled]'
  }
})
export class DropdownDirective extends BtnDirective {}

btn.directive.ts

import { Directive } from '@angular/core';

@Directive({
  selector: `button[perf-btn], button[perf-btn], a[perf-btn], input[perf-btn],
            div[perf-btn], perf-btn`,
  host: {
    '[class.btn]': 'true'
  }
})
export class BtnDirective {}
like image 773
user2706191 Avatar asked Feb 16 '17 15:02

user2706191


2 Answers

Although <ng-content> can't emit per se, the actual component behind the content can.

Indeed, the nested component can have an EventEmitter like the following :

@Output()
nestedComponentChange: EventEmitter<number> = new EventEmitter<number>();

The parent component can then listen :


@ContentChildren(MyNestedComponent) templates: QueryList<MyNestedComponent>;

ngAfterContentInit() {
    this.templates.forEach((template) => {
      template.nestedComponentChange.subscribe(() => doSomething());
    });
}

There is no need for a service in the case above.

like image 177
ojathelonius Avatar answered Oct 03 '22 04:10

ojathelonius


Thanks to @AngularFrance, I learned that ng-content cannot emit. However, a service can communicate between the parent component and the child ng-content instead.

See // comments for what I added to the original components to make them work with the service.

Also see Bidirectional Communication for a cookbook recipe.

btn.service.ts

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class BtnService {
  private _stateSource: Subject<boolean> = new Subject<boolean>();
  public state$: Observable<Subject<boolean>> = this._stateSource.asObservable();

  public toggle(state: boolean): void {
    console.log("toggling");
    this._stateSource.next(state);
  }

  public close(): void {
    this._stateSource.next(false);
  }

  public open(): void {
    this._stateSource.next(true);
  }
}

dropdown.component.ts

import { Component, Input, ElementRef, OnDestroy } from '@angular/core';
import { Subscription }   from 'rxjs/Subscription';    // import subscription

import { BtnService } from './btn.service';            // import service

@Component({
  selector: 'perf-drop',
  host: {
    '[attr.disabled]': 'disabled',
    '[class.open]': '_isOpen'
  },
  templateUrl: 'dropdown.component.html',
  styleUrls: ['dropdown.component.scss'],
  providers: [BtnService]                              // add service as provider to parent
})
export class DropdownComponent implements OnDestroy {
  private _data: any[] = [];
  private _isOpen: boolean = false;
  private _subscription: Subscription;

  get isOpen() { return this._isOpen; }
  set isOpen(value: boolean) { this._isOpen = value ? true : null; }

  @Input()
  get data(): any[] { return this._data; }
  set data(value: any[]) {
    this._data = value;
  }

  constructor(private _elementRef: ElementRef,
    private _service: BtnService) {                      // add to constructor
      this._subscription = _service.state$.subscribe(    // subscribe to service
        state => {
          this._isOpen = state;
        })
    }

  ngOnDestroy() {
    // prevent memory leak when component destroyed
    this._subscription.unsubscribe();
  }
}

btn.component.ts

import { Component, ViewEncapsulation, Input, HostBinding, ChangeDetectionStrategy,
  ElementRef, Renderer, EventEmitter } from '@angular/core';

import { BtnService } from './btn.service';                   // import service

@Component({
  selector: 'button[perf-btn], input[perf-btn], a[perf-btn], div[perf-btn], perf-btn',
  host: {
    "(click)": "_dropdown && _toggle()",
    "(document:click)": "_dropdown && _close()"
  },
  templateUrl: './btn.component.html',
  styleUrls: ['./btn.component.scss']
})
export class BtnComponent {
  private _dropdown: boolean;
  private _state: boolean;

  @Input()
  get dropdown() { return this._dropdown; }
  set dropdown(value: boolean) { this._dropdown = value ? true : null; }

  @Input()
  get state() { return this._state; }
  set state(value: boolean) { this._state = value; }

  constructor(private _elementRef: ElementRef, private _renderer: Renderer,
    private _service: BtnService) {                      // add service to constructor
  }

  _toggle() {
    this._state = !this._state;
    this._service.toggle(this._state);                   // call service
  }

  _close() {
    if (!this._elementRef.nativeElement.contains(event.target) && this._state) {
      this._state = false;
      this._service.close();                             // call service
    }
  }
}
like image 38
user2706191 Avatar answered Oct 03 '22 04:10

user2706191