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 {}
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.
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
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With