I have two Angular components (accordion > expansion panels) that I need to write without a framework. I am using ContentChild to support all of the expansion panels contained within the accordion component. What I want to do is parse the components to check their state and switch update a value on a property from within the parent container.
Right now I have an event emitter in each expansion-panel which emits if each instance is open or closed.
I need to track each ChildContent state$ and after each has been checked set a default if there is one, and track any changes if an inner child instance is toggled, it should emit the event to the parent accordion so it can determine which index to track as expanded.
I wish to loop through each instance of ChildContent and check if any have been opened, if not open the first instance by changing its state. In the wrapper, I subscribe to each panel and check in a loop if they have are open, if none are open I wish to expand the first child.
Whenever a child panel expands, I wish to contract the remaining child accordions aside from the one selected.
@Component({
selector: 'app-accordion',
templateUrl: './accordion.component.html'
})
export class AccordionComponent extends Destroyable implements AfterContentInit, OnDestroy {
@ContentChildren(ExpansionPanelComponent) expansionPanels: QueryList<ExpansionPanelComponent>;
...
Here is the inner child component (the expansion panel)
@Component({
selector: 'app-expansion-panel',
templateUrl: './expansion-panel.component.html'
})
export class ExpansionPanelComponent extends Destroyable implements OnInit, OnDestroy {
@Input() title!: string;
@Input() content!: string | string[];
/**
* The default for this input would be overwritten if the optional
* @Input() defaultState is provided during initialization.
* The defaultState override occurs inside of this.setDefaultState();
*/
@Input() defaultState: ExpansionPanelState = ExpansionPanelState.Collapsed;
@Input() footerContent?: string;
@Input() headerIcon?: IconName;
@Input() toggleButton?: ViewChild;
@Output() expanded$ = new EventEmitter<ExpansionPanelState>();
setState(expansionPanelState: ExpansionPanelState): void {
if (this.expanded$.observers.length > 0) {
this.expanded$.emit(expansionPanelState);
}
this.state$.next(expansionPanelState);
}
Here is the container component (Accordion) template to give an idea of how simple this should be...
{{ accordionTitle }} {{ accordionSubtitle }} /** ' * The ContentChildren renders ALL of the child components as expanded, no * matter what approach I have tried. I have verified that the event is being * dispatched to the parent, I also see the individual panels toggling as I * expect. What cant get is the dynamic mutually exclusive behavior described * above when the user expands one panel, it should contract the rest. When * the panels are used alone, they should be able to be configured and used * independently of the accordion (this is fine now). How to map/mutate/set * this once I have dynamically adjusted the values of the child * components state? */The problem is that since ContentChild is wrapped in a I cannot reassign a mapped version back to it once I use the ContentChild.prototype.map method, and I want to avoid the overhead of manually rendering the component.
The expansion panel works fine, unit tested, and all. The accordion and expansion panel filtering and selection have me stumped. I keep getting infinite loops etc, I know I there must be an easier way to dynamically update ContentChildren, can anyone steer me in the right direction?
So how do I query and then update the ContentChildren from within its wrapper component?
https://codesandbox.io/s/silly-river-jsbuu?file=/src/app/accordion/accordion.component.ts:0-2156
The mapping would happen in AccordionComponent.ngAfterContentInit() but I cant make it work with the approach I have in the interactive sandbox linked below.
Thanks!
To do the one thing you asked for in your question, in your accordion, initializing all the panels closed, can be done by iterating over values of the already existing expansionPanels
instance attribute:
import { ExpansionPanelState } from "PATH/TO/FILE/expansion-panel.model";
ngAfterContentInit() {
this.expansionPanels
.forEach(panel => panel.setState(ExpansionPanelState.Collapsed));
}
As a suggestion: in your ExpansionPanelComponent
's template there's no need to send the state back to typescript code because you already have access to it in there. So change it to:
<header (click)="toggle()"...
Now let's do some modifications in the toggle
method to get the current state of your BehaviorSubject
, instead of destructuring it to get its value. We don't need to do almost anything because the BehaviorSubject
already reemits the last value to new subscribers. We'll use the take
operator to make sure we'll unsubscribe after taking the last emission:
import {take} from 'rxjs/operators';
...
toggle(): void {
this.state$.pipe(take(1)).subscribe((value: ExpansionPanelState) => {
switch (value) {...}
});
}