src/app/utils/tab-state/tab-state-memo.directive.ts

Description

Memorizes the current state of a TabGroup (i.e. which tab currently is selected) in the URL and sets the initial value to a value that was previously set.

This enables navigation throughout the app while memorizing the state a tab was in Usage (only the directive is required, the rest is automatic):

Example :
<mat-tab-group appTabStateMemo>
  ...
</mat-tab-group>

When tab groups are nested within the same route (e.g. an entity-details tab group containing a component that itself uses a tab group), each must use a distinct tabIndexKey. Otherwise they share the same query param and reset each other on every navigation.

Implements

OnInit

Example

Metadata

Relationships

Used by

Depends on

Index

Inputs

Inputs

tabIndexKey
Type : string
Default value : "tabIndex"

The query param key under which this tab group's selected index is stored. Override this for tab groups nested within another tab group on the same route so they don't clobber each other's state.

import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
import { MatTabGroup } from "@angular/material/tabs";
import {
  ChangeDetectorRef,
  Directive,
  Input,
  OnInit,
  inject,
} from "@angular/core";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { ViewComponentContext } from "../../core/ui/abstract-view/view-component-context";
import { filter, startWith } from "rxjs/operators";

/**
 * Memorizes the current state of a `TabGroup` (i.e. which tab currently is selected)
 * in the URL and sets the initial value to a value that was previously set.
 *
 * This enables navigation throughout the app while memorizing the state a tab was in
 * Usage (only the directive is required, the rest is automatic):
 * ```
 * <mat-tab-group appTabStateMemo>
 *   ...
 * </mat-tab-group>
 * ```
 *
 * When tab groups are nested within the same route (e.g. an entity-details tab
 * group containing a component that itself uses a tab group), each must use a
 * distinct `tabIndexKey`. Otherwise they share the same query param and reset
 * each other on every navigation.
 */
@UntilDestroy()
@Directive({
  selector: "[appTabStateMemo]",
  // eslint-disable-next-line @angular-eslint/prefer-standalone
  standalone: false,
})
export class TabStateMemoDirective implements OnInit {
  private router = inject(Router);
  private route = inject(ActivatedRoute);
  private tab = inject(MatTabGroup);
  private cdr = inject(ChangeDetectorRef);
  private viewContext = inject(ViewComponentContext, { optional: true });

  /**
   * The query param key under which this tab group's selected index is stored.
   * Override this for tab groups nested within another tab group on the same
   * route so they don't clobber each other's state.
   */
  @Input() tabIndexKey = "tabIndex";

  ngOnInit() {
    if (this.viewContext?.isDialog) {
      // does not apply if opened in popup
      return;
    }

    // React to NavigationEnd (not just snapshot) so that navigating to
    // ?tabIndex=N while already on this page also updates the tab.
    // startWith(null) covers the initial load synchronously.
    this.router.events
      .pipe(
        filter((e) => e instanceof NavigationEnd),
        startWith(null),
        untilDestroyed(this),
      )
      .subscribe(() => {
        const potentialNextTabIndex = parseInt(
          this.route.snapshot.queryParamMap.get(this.tabIndexKey),
          10,
        );
        if (
          !Number.isNaN(potentialNextTabIndex) &&
          this.tab.selectedIndex !== potentialNextTabIndex
        ) {
          this.tab.selectedIndex = potentialNextTabIndex;
          // Trigger change detection so MatTabGroup actually applies the switch.
          // Without this, an in-app navigation that only changes the tabIndex
          // query param updates the URL but not the visible tab (OnPush parents).
          this.cdr.markForCheck();
        }
      });
    this.tab.selectedIndexChange
      .pipe(untilDestroyed(this))
      .subscribe((next) => this.updateURLQueryParams(next));
  }

  // Update the URL
  private async updateURLQueryParams(value: number) {
    if (this.viewContext?.isDialog) {
      // does not apply if opened in popup
      return;
    }

    await this.router.navigate(["."], {
      relativeTo: this.route,
      queryParams: { [this.tabIndexKey]: value },
      replaceUrl: true,
      queryParamsHandling: "merge",
    });
  }
}

results matching ""

    No results matching ""