src/app/core/admin/building-blocks/admin-tabs/admin-tabs.component.ts

Description

Building block for drag&drop form builder to let an admin user manage multiple tabs.

Provide a template for the tab content when using this component: <app-admin-tabs [tabs]="myTabsArray" [newTabFactory]="newTabFactory"

<ng-template [appAdminTabTemplate]="myTabsArray" let-item> {{ item.title }}

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs
Outputs

Inputs

newTabFactory
Type : () => E
Default value : () => ({ [this.tabTitleProperty()]: "" }) as E
tabs
Type : E[]
Default value : []

Outputs

tabs
Type : E[]

Methods

createTab
createTab()
Returns : void
drop
drop(event: CdkDragDrop)
Parameters :
Name Type Optional
event CdkDragDrop<string[]> No
Returns : void
removeTab
removeTab(index: number)
Parameters :
Name Type Optional
index number No
Returns : void

Properties

allTabIds
Type : unknown
Default value : computed<string[][]>(() => this.tabs().map((_, index, tabs) => tabs.map((__, i) => "tabs-" + i).filter((_, i) => i !== index), ), )

For each tab, the list of other tab element ids required for linking drag&drop targets due to the complex template of tab headers.

tabGroup
Type : MatTabGroup
Decorators :
@ViewChild(MatTabGroup)
tabTemplate
Type : TemplateRef<any>
Decorators :
@ContentChild(undefined, {read: TemplateRef})
tabTitleProperty
Type : unknown
Default value : computed<"title" | "name">(() => { const tabs = this.tabs(); if (!tabs || tabs.length < 1) { return "title"; } return tabs[0].hasOwnProperty("name") ? "name" : "title"; })
import {
  Component,
  ContentChild,
  TemplateRef,
  ViewChild,
  computed,
  ChangeDetectionStrategy,
  input,
  model,
} from "@angular/core";
import { CommonModule } from "@angular/common";
import { AdminSectionHeaderComponent } from "../admin-section-header/admin-section-header.component";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { MatButton } from "@angular/material/button";
import {
  MatTab,
  MatTabContent,
  MatTabGroup,
  MatTabLabel,
} from "@angular/material/tabs";
import { MatTooltip } from "@angular/material/tooltip";
import { AdminTabTemplateDirective } from "./admin-tab-template.directive";
import {
  CdkDragDrop,
  DragDropModule,
  moveItemInArray,
} from "@angular/cdk/drag-drop";

/**
 * Building block for drag&drop form builder to let an admin user manage multiple tabs.
 *
 * Provide a template for the tab content when using this component:
 <app-admin-tabs
 [tabs]="myTabsArray"
 [newTabFactory]="newTabFactory"
 >
 <!-- provide the array of tabs to the `appAdminTabTemplate` to infer typing -->
 <ng-template [appAdminTabTemplate]="myTabsArray" let-item>
 {{ item.title }} <!-- use the tab entry in your template -->
 </ng-template>
 </app-admin-tabs>
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-admin-tabs",
  imports: [
    CommonModule,
    AdminSectionHeaderComponent,
    FaIconComponent,
    MatButton,
    MatTab,
    MatTabContent,
    MatTabGroup,
    MatTabLabel,
    MatTooltip,
    DragDropModule,
  ],
  templateUrl: "./admin-tabs.component.html",
  styleUrl: "./admin-tabs.component.scss",
})
export class AdminTabsComponent<
  E extends { title: string } | { name: string },
> {
  tabs = model<E[]>([]);
  newTabFactory = input<() => E>(
    () => ({ [this.tabTitleProperty()]: "" }) as E,
  );

  tabTitleProperty = computed<"title" | "name">(() => {
    const tabs = this.tabs();
    if (!tabs || tabs.length < 1) {
      return "title";
    }
    return tabs[0].hasOwnProperty("name") ? "name" : "title";
  });

  @ContentChild(AdminTabTemplateDirective<E>, { read: TemplateRef })
  tabTemplate: TemplateRef<any>;

  @ViewChild(MatTabGroup) tabGroup: MatTabGroup;

  createTab() {
    const newTab = this.newTabFactory()();
    this.tabs.update((tabs) => [...tabs, newTab]);

    // wait until view has actually added the new tab before we can auto-select it
    setTimeout(() => {
      const newTabIndex = this.tabs().length - 1;
      this.tabGroup.selectedIndex = newTabIndex;
      this.tabGroup.focusTab(newTabIndex);
    });
  }

  removeTab(index: number) {
    this.tabs.update((tabs) =>
      tabs.filter((_, tabIndex) => tabIndex !== index),
    );
    this.tabGroup.selectedIndex = this.computeSelectedIndexAfterRemove(
      index,
      this.tabGroup.selectedIndex ?? 0,
    );
  }

  private computeSelectedIndexAfterRemove(
    removedIndex: number,
    activeIndex: number,
  ): number {
    if (removedIndex < activeIndex) {
      return activeIndex - 1;
    } else if (removedIndex === activeIndex) {
      return Math.max(0, activeIndex - 1);
    } else {
      return activeIndex;
    }
  }

  /**
   * For each tab, the list of other tab element ids required for linking drag&drop targets
   * due to the complex template of tab headers.
   */
  allTabIds = computed<string[][]>(() =>
    this.tabs().map((_, index, tabs) =>
      tabs.map((__, i) => "tabs-" + i).filter((_, i) => i !== index),
    ),
  );

  drop(event: CdkDragDrop<string[]>) {
    const previousIndex = parseInt(
      event.previousContainer.id.replace("tabs-", ""),
    );
    const currentIndex = parseInt(event.container.id.replace("tabs-", ""));

    const previouslySelectedTab = this.tabs()[this.tabGroup.selectedIndex];

    this.tabs.update((tabs) => {
      const nextTabs = [...tabs];
      moveItemInArray(nextTabs, previousIndex, currentIndex);
      return nextTabs;
    });

    // re-select the previously selected tab, even after its index shifted
    let shiftedSelectedIndex = this.tabs().indexOf(previouslySelectedTab);
    if (shiftedSelectedIndex !== this.tabGroup.selectedIndex) {
      this.tabGroup.selectedIndex = shiftedSelectedIndex;
      this.tabGroup.focusTab(shiftedSelectedIndex);
    }

    this.tabs.update((tabs) => JSON.parse(JSON.stringify(tabs))); // Needed to avoid Angular Ivy render bug
  }
}
<mat-tab-group [preserveContent]="true" #matTabGroup>
  @for (tab of tabs(); track $index; let tabIndex = $index) {
    <mat-tab>
      <ng-template mat-tab-label>
        <div
          class="drop-list flex-row"
          [id]="'tabs-' + tabIndex"
          cdkDropList
          cdkDropListOrientation="horizontal"
          (cdkDropListDropped)="drop($event)"
          [cdkDropListConnectedTo]="allTabIds()[tabIndex]"
        >
          <div
            cdkDrag
            cdkDragLockAxis="x"
            class="flex-row align-center drop-item gap-small"
          >
            <fa-icon
              icon="grip-vertical"
              size="xl"
              class="drag-handle"
            ></fa-icon>
            @if (tabIndex === matTabGroup.selectedIndex) {
              <app-admin-section-header
                [(title)]="tab[tabTitleProperty()]"
                (remove)="removeTab(tabIndex)"
              ></app-admin-section-header>
            } @else {
              <!-- only current tab can be renamed -->
              {{ tab[tabTitleProperty()] }}
            }
          </div>
        </div>
      </ng-template>
      <ng-template matTabContent>
        <ng-template
          *ngTemplateOutlet="
            tabTemplate;
            context: { $implicit: tab, index: tabIndex }
          "
        ></ng-template>
      </ng-template>
    </mat-tab>
  }

  <!-- Add new tab -->
  <mat-tab>
    <ng-template mat-tab-label>
      <button
        mat-stroked-button
        color="accent"
        matTooltip="add a new tab"
        i18n-matTooltip
        (click)="createTab()"
      >
        <fa-icon i18n-aria-label aria-label="add" icon="plus-circle"></fa-icon>
      </button>
    </ng-template>
  </mat-tab>
</mat-tab-group>

./admin-tabs.component.scss

@use "variables/colors";

:host ::ng-deep .mat-mdc-tab {
  /* adjust tab header height to properly display mat-form-field for editing tab label */
  height: 60px !important;
}
app-admin-section-header {
  margin-bottom: -1em;
}
.drag-handle {
  cursor: move;
  margin: auto;
  color: colors.$accent;
}

.cdk-drag-preview {
  box-sizing: border-box;
  border-radius: 4px;
  box-shadow:
    0 5px 5px -3px rgba(0, 0, 0, 0.2),
    0 8px 10px 1px rgba(0, 0, 0, 0.14),
    0 3px 14px 2px rgba(0, 0, 0, 0.12);
}

.cdk-drag-placeholder {
  opacity: 0;
}

.cdk-drag-animating {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

.drop-list.cdk-drop-list-dragging .drop-item:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""