Search code examples
angularangular-cdk-overlay

angular - Preserve state of overlay


I've been building a component library for angular which contains an accordion and offcanvas component. I've created a minimal StackBlitz with a demo.

Now I want to be able to preserve the expanded states of the accordion in the sidebar. Currently, when showing the sidebar, and expanding multiple accordions, when you close and reopen the sidebar all accordions are collapsed again.

The offcanvas is shown with the following code snippet:

const injector = Injector.create({
  providers: [{ provide: 'OFFCANVAS_CONTENT', useValue: template }],
  parent: this.parentInjector
});

// Portal which will hold the offcanvas component
const portal = new ComponentPortal(BsOffcanvasComponent, null, injector);

// Create overlay to the left side of the screen
const overlay = this.overlayService.create({
  scrollStrategy: this.overlayService.scrollStrategies.reposition(),
  positionStrategy: this.overlayService.position().global()
    .centerVertically().left('0'),
  height: '100%',
  hasBackdrop: true
});

// Instantiate the offcanvas. This will resolve the provider,
// and render the template in the bootstrap 5 offcanvas
const componentInstance = overlay.attach<BsOffcanvasComponent>(portal);

The code renders the template passed through the OFFCANVAS_CONTENT provider inside the OffcanvasComponent with the following template:

<div class="offcanvas overflow-hidden" [class]="offcanvasClass$ | async"
     [class.show]="show$ | async"
     [class.h-100]="offcanvasHeight100$ | async"
     [style.height.px]="size"
     [class.oc-max-width]="['start', 'end'].includes(position)"
     [class.oc-max-height]="['top', 'bottom'].includes(position)">
    <ng-container *ngTemplateOutlet="content; context: { $implicit: this }" ></ng-container>
</div>

In the example I'm rendering an AccordionComponent inside the overlay's template:

<body>
  <button (click)="toggleSidebar(sidebarTemplate)">Toggle sidebar</button>
  <ng-template #sidebarTemplate let-offcanvas>
    <app-sidebar></app-sidebar>
  </ng-template>
</body>

But since I'm creating a new component instance based on a TemplateRef each time the offcanvas is shown, the state of the components inside the sidebar is lost again.

How can I circumvent this? It seems impossible to render a component instance inside a CDK overlay, you can only render a template inside a CDK overlay.


Solution

  • I took another approach here, and decided to render the TemplateRef in the CDK Overlay in the AfterViewInit hook (so right after the host component is created/rendered). I now do this only once, so the entire state of whatever's inside the CDK Overlay is preserved.

    Other than that, it's just a matter of responding to changes in the Input bindings.

    StackBlitz demo

    Old solution

    Solved this by creating 2 directives that persist the active tab in a field on the AppComponent.

    from-overlay

    @Directive({
      selector: 'bs-accordion[bsFromOverlay]'
    })
    export class BsFromOverlayDirective implements AfterContentInit, OnDestroy {
    
      constructor(private accordion: BsAccordionComponent) {
        this.accordion.disableAnimations = true;
        combineLatest([this.inited$, this.activeOverlayIdentifier$])
          .pipe(filter(([inited, activeOverlayIdentifier]) => inited))
          .pipe(takeUntil(this.destroyed$))
          .subscribe(([inited, activeOverlayIdentifier]) => {
            this.bsFromOverlayChange.emit(activeOverlayIdentifier);
            this.accordion.tabPages.forEach((tab) => {
              tab.isActive = (tab["tabOverlayIdentifier"] == activeOverlayIdentifier);
            });
            setTimeout(() => this.accordion.disableAnimations = false, 30);
          });
      }
    
      private readonly inited$ = new BehaviorSubject<boolean>(false);
      private readonly destroyed$ = new Subject();
      private readonly activeOverlayIdentifier$ = new BehaviorSubject<string | null>(null);
    
      @Output() public bsFromOverlayChange = new EventEmitter<string | null>();
      private _bsFromOverlay: string | null = null;
    
      /** Binds the active tab of an accordion to a field, in case the accordion is rendered in an overlay. */
      public get bsFromOverlay() {
        return this._bsFromOverlay;
      }
      @Input() public set bsFromOverlay(value: string | null) {
        if (this._bsFromOverlay != value) {
          this._bsFromOverlay = value;
          this.activeOverlayIdentifier$.next(value);
        }
      }
    
      @ContentChildren(BsFromOverlayIdDirective, { read: ElementRef }) tabPages!: QueryList<ElementRef<BsAccordionTabComponent>>;
      
      ngAfterContentInit() {
        this.inited$.next(true);
      }
      ngOnDestroy() {
        this.destroyed$.next(true);
      }
    }
    

    from-overlay-id

    @Directive({
      selector: 'bs-accordion-tab[bsFromOverlayId]'
    })
    export class BsFromOverlayIdDirective implements OnDestroy {
      constructor(private accordionTab: BsAccordionTabComponent, private bsFromOverlay: BsFromOverlayDirective) {
        this.accordionTab.isActiveChange
          .pipe(takeUntil(this.destroyed$))
          .subscribe((isActive) => {
            if (isActive) {
              bsFromOverlay.bsFromOverlay = this.bsFromOverlayId;
            } else {
              bsFromOverlay.bsFromOverlay = null;
            }
          });
      }
    
      private destroyed$ = new Subject();
      ngOnDestroy() {
        this.destroyed$.next(true);
      }
    
      //#region bsFromOverlayId
      private _bsFromOverlayId!: string;
      /** String value containing the accordion tab identifier. */
      public get bsFromOverlayId() {
        return this._bsFromOverlayId;
      }
      @Input() public set bsFromOverlayId(value: string) {
        this._bsFromOverlayId = value;
        this.accordionTab['tabOverlayIdentifier'] = value;
      }
      //#endregion
    }