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
|
newTabFactory
|
Type : () => E
|
Default value : () => ({ [this.tabTitleProperty()]: "" }) as E
|
|
|
|
tabs
|
Type : E[]
|
Default value : []
|
|
|
Methods
|
drop
|
drop(event: CdkDragDrop)
|
|
|
Parameters :
| Name |
Type |
Optional |
| event |
CdkDragDrop<string[]>
|
No
|
|
|
removeTab
|
removeTab(index: number)
|
|
|
Parameters :
| Name |
Type |
Optional |
| index |
number
|
No
|
|
|
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>
@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 with directive