Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

replace bootstrapified "tab group" dashboard card with an actual angular component #97

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,750 changes: 12,750 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions src/app/components/components.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,28 @@ import { RouterModule } from '@angular/router';
import { FooterComponent } from './footer/footer.component';
import { NavbarComponent } from './navbar/navbar.component';
import { SidebarComponent } from './sidebar/sidebar.component';
import { DashboardTabCardComponent, DashboardCardTab, DashboardTabCardHeader, DashboardCardTabLabel } from './dashboard-tab-card/dashboard-tab-card.component';
import { PortalModule } from '@angular/cdk/portal';
import { MatButtonModule } from '@angular/material/button';

@NgModule({
imports: [
CommonModule,
RouterModule,
PortalModule,
MatButtonModule,
],
declarations: [
FooterComponent,
NavbarComponent,
SidebarComponent
SidebarComponent,
DashboardTabCardComponent, DashboardCardTab, DashboardTabCardHeader, DashboardCardTabLabel,
],
exports: [
FooterComponent,
NavbarComponent,
SidebarComponent
SidebarComponent,
DashboardTabCardComponent, DashboardCardTab, DashboardTabCardHeader, DashboardCardTabLabel,
]
})
export class ComponentsModule { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="tab-pane" [class.active]="isActive" [id]="tabId">
<ng-template><ng-content></ng-content></ng-template>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Component, OnInit, ContentChild, ElementRef, ViewContainerRef, Inject, InjectionToken, TemplateRef, Directive, ViewChild, Input, SimpleChanges } from "@angular/core";
import { Subject } from "rxjs";
import { TemplatePortal, CdkPortal } from "@angular/cdk/portal";

/**
* Used to provide a card to a tab without causing a circular dependency.
*/
export const MATERIAL_ADMIN_DASHBOARD_CARD = new InjectionToken<any>('MATERIAL_ADMIN_DASHBOARD_CARD');

export const MATERIAL_ADMIN_DASHBOARD_CARD_LABEL = new InjectionToken<DashboardCardTabLabel>('DashboardCardTabLabel');

/* -== TAB LABEL ==- */

/** Used to flag tab labels for use with the portal directive */
@Directive({
selector: '[dashboardCardTabLabel]',
providers: [{provide: MATERIAL_ADMIN_DASHBOARD_CARD_LABEL, useExisting: DashboardCardTabLabel}],
})
export class DashboardCardTabLabel extends CdkPortal { }

/* -== TAB CONTENT ==- */

export const MATERIAL_ADMIN_DASHBOARD_CARD_CONTENT = new InjectionToken<DashboardCardTabContent>('DashboardCardTabContent');

/** Decorates the `ng-template` tags and reads out the template from it. */
@Directive({
selector: '[dashboardCardTabContent]',
providers: [{provide: MATERIAL_ADMIN_DASHBOARD_CARD_CONTENT, useExisting: DashboardCardTabContent}],
})
export class DashboardCardTabContent {
constructor(public template: TemplateRef<any>) { }
}

/* -== TAB ==- */

@Component({
selector: 'dashboard-card-tab',
templateUrl: './dashboard-card-tab.component.html',
styles: [``],
})
export class DashboardCardTab implements OnInit {
private _id: string | undefined;
readonly _stateChanges = new Subject<void>(); // Emits whenever the internal state of the tab changes.

/** tab content */
private _contentPortal: TemplatePortal|null = null;
get content(): TemplatePortal|null { return this._contentPortal; }

/** Content for the tab label given by `<ng-template dashboardCardTabLabel>`. */
@ContentChild(DashboardCardTabLabel)
get templateLabel(): DashboardCardTabLabel { return this._templateLabel; }
set templateLabel(value: DashboardCardTabLabel) { if (value) this._templateLabel = value; }
protected _templateLabel!:DashboardCardTabLabel;

constructor(private _elementRef: ElementRef, private _viewContainerRef: ViewContainerRef, @Inject(MATERIAL_ADMIN_DASHBOARD_CARD) public card:any) {
}

/* Inputs / Outputs */

// Template provided in the tab content that will be used if present, used to enable lazy-loading
@ContentChild(MATERIAL_ADMIN_DASHBOARD_CARD_CONTENT as any, {read: TemplateRef, static: true})
_explicitContent!:TemplateRef<any>;
// everything in the child
@ViewChild(TemplateRef, {static: true}) _implicitContent!:TemplateRef<any>;

@Input()
public set id(id: string) {
this._id = id;
}

public get tabId(): string | undefined {
return this._id;
}

public isActive:boolean = false;
public position:number|null = null;
public disabled:boolean = false;

@Input('label') textLabel: string = ''; // Plain text label for the tab, used when there is no template label.


/** State change handlers */

ngOnChanges(changes: SimpleChanges): void {
if (changes.hasOwnProperty('textLabel') || changes.hasOwnProperty('disabled')) this._stateChanges.next();
}

ngOnDestroy(): void {
this._stateChanges.complete();
}

ngOnInit(): void {
this._contentPortal = new TemplatePortal(this._explicitContent || this._implicitContent, this._viewContainerRef);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="card-header card-header-tabs" [ngClass]="headerType"><ng-content></ng-content></div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* -== CARD HEADER ==- */

import { Component, OnInit, Input } from "@angular/core";

@Component({
selector: 'dashboard-tab-card-header',
templateUrl: 'dashboard-tab-card-header.component.html',
styles: [``]
})
export class DashboardTabCardHeader implements OnInit {
ngOnInit(): void { }

@Input()
public selectedIndex:number = 0;

@Input()
public color:string|null = null;

public get headerType(): string {
return `card-header-${this.color}`;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<div class="card">
<dashboard-tab-card-header #tabHeader [selectedIndex]="selectedIndex || 0" [color]="color">
<div class="nav-tabs-navigation">
<div class="nav-tabs-wrapper">
<span class="nav-tabs-title">{{label}}</span>
<ul class="nav nav-tabs">
<li role="tab" class="nav-item" *ngFor="let tab of _tabs; let i = index" [id]="_getTabLabelId(i)" [class.mat-tab-label-active]="selectedIndex == i">
<a mat-button class="nav-link" href="javascript:void(0)" [class.active]="selectedIndex == i" [disabled]="tab.disabled" (click)="_handleClick(tab, i)">
<ng-template [ngIf]="tab.templateLabel">
<ng-template [cdkPortalOutlet]="tab.templateLabel"></ng-template>
</ng-template>
<ng-template [ngIf]="!tab.templateLabel">{{tab.textLabel}}</ng-template>
</a>
</li>
</ul>
</div>
</div>
</dashboard-tab-card-header>

<div class="card-body">
<div class="tab-content">
<ng-template [cdkPortalOutlet]="selectedTab?.content"></ng-template>
</div>
</div>

</div>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { DashboardTabCardComponent } from './dashboard-tab-card.component';

describe('DashboardCardComponent', () => {
let component: DashboardTabCardComponent;
let fixture: ComponentFixture<DashboardTabCardComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ DashboardTabCardComponent ]
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(DashboardTabCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
174 changes: 174 additions & 0 deletions src/app/components/dashboard-tab-card/dashboard-tab-card.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { coerceNumberProperty } from '@angular/cdk/coercion';
import { Component, OnInit, Input, QueryList, ElementRef, ChangeDetectorRef, EventEmitter, Output, ContentChildren, ViewEncapsulation, ViewChild } from '@angular/core';
import { Subscription, merge } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { DashboardTabCardHeader } from './dashboard-tab-card-header.component';
import { DashboardCardTab, MATERIAL_ADMIN_DASHBOARD_CARD } from './dashboard-card-tab.component';
export { DashboardTabCardHeader } from './dashboard-tab-card-header.component';
export { DashboardCardTab, DashboardCardTabLabel, MATERIAL_ADMIN_DASHBOARD_CARD } from './dashboard-card-tab.component';

let nextId = 0; // used to generate unique IDs for dashboard card components

/** A simple change event emitted on focus or selection changes. */
export class DashboardCardTabChangeEvent {
constructor(public index: number, public tab: DashboardCardTab|undefined) {}
}

/* -== CARD ==- */

@Component({
selector: 'dashboard-tab-card',
templateUrl: './dashboard-tab-card.component.html',
styleUrls: ['./dashboard-tab-card.component.scss'],
encapsulation: ViewEncapsulation.None,
providers: [{ provide: MATERIAL_ADMIN_DASHBOARD_CARD, useExisting: DashboardTabCardComponent }],
host: { 'class': 'material-admin-dashboard-card', },
})
export class DashboardTabCardComponent implements OnInit {
@ContentChildren(DashboardCardTab, {descendants: true}) _allTabs!:QueryList<DashboardCardTab>;
@ViewChild('tabHeader') _tabHeader!:DashboardTabCardHeader;

_tabs: QueryList<DashboardCardTab> = new QueryList<DashboardCardTab>();
private _tabsSubscription = Subscription.EMPTY;
private _tabLabelSubscription = Subscription.EMPTY; // Subscription to changes in the tab labels.
/** The tab index that should be selected after the content has been checked. */
private _indexToSelect: number | null = 0;

private _cardId = nextId++;

constructor(private _elementRef: ElementRef, protected _changeDetectorRef: ChangeDetectorRef) {}

ngOnInit(): void {}

/* Inputs */

@Input()
public color: string = 'primary';

@Input()
public disableRipple:boolean = false;

@Input()
public label:string = '';

/* Outputs */

/** Output to enable support for two-way binding on `[(selectedIndex)]` */
@Output() readonly selectedIndexChange: EventEmitter<number> = new EventEmitter<number>();
/** Event emitted when the tab selection has changed. */
@Output()
readonly selectedTabChange: EventEmitter<DashboardCardTabChangeEvent> = new EventEmitter<DashboardCardTabChangeEvent>(true);

/** The index of the active tab. */
@Input()
get selectedIndex(): number | null { return this._selectedIndex; }
set selectedIndex(value: number | null) {
this._indexToSelect = coerceNumberProperty(value, null);
}
private _selectedIndex: number | null = null;

/* design states */

_getTabLabelId(i: number): string { return `dashboard-card-tab-label-${this._cardId}-${i}`; }
_getTabContentId(i: number): string { return `dashboard-card-tab-content-${this._cardId}-${i}`; }

public get selectedTab():DashboardCardTab|undefined {
return this._tabs.find(tab => tab.position == 0); // position gets recalculated relative to the selected tab
}

/* State change handlers */

/**
* After the content is checked, this component knows what tabs have been defined
* and what the selected index should be. This is where we can know exactly what position
* each tab should be in according to the new selected index, and additionally we know how
* a new selected tab should transition in (from the left or right).
*/
ngAfterContentChecked() {
// Don't clamp the `indexToSelect` immediately in the setter because it can happen that
// the amount of tabs changes before the actual change detection runs.
const indexToSelect = this._indexToSelect = this._clampTabIndex(this._indexToSelect);

// If there is a change in selected index, emit a change event. Should not trigger if
// the selected index has not yet been initialized.
if (this._selectedIndex != indexToSelect) {
const isFirstRun = this._selectedIndex == null;

if (!isFirstRun) {
this.selectedTabChange.emit(this._createChangeEvent(indexToSelect));
}

// Changing these values after change detection has run
// since the checked content may contain references to them.
Promise.resolve().then(() => {
this._tabs.forEach((tab, index) => { tab.isActive = index === indexToSelect; });
if (!isFirstRun) this.selectedIndexChange.emit(indexToSelect);
});
}

// Setup the position for each tab and optionally setup an origin on the next selected tab.
this._tabs.forEach((tab:DashboardCardTab, index:number) => { tab.position = index - indexToSelect; });

if (this._selectedIndex !== indexToSelect) {
this._selectedIndex = indexToSelect;
this._changeDetectorRef.markForCheck();
}
}

ngAfterContentInit() {
// generate _tabs from _allTabs
this._allTabs.changes.pipe(startWith(this._allTabs)).subscribe((tabs: QueryList<DashboardCardTab>) => {
this._tabs.reset(tabs.filter(tab => tab.card === this));
this._tabs.notifyOnChanges();
});
// monitor changes in tab labels
this._tabLabelSubscription.unsubscribe();
this._tabLabelSubscription = merge(...this._tabs.map(tab => tab._stateChanges)).subscribe(() => this._changeDetectorRef.markForCheck());

this._tabsSubscription = this._tabs.changes.subscribe(() => {
const indexToSelect = this._clampTabIndex(this._indexToSelect);
// Maintain the previously-selected tab if a new tab is added or removed and there is no
// explicit change that selects a different tab.
if (indexToSelect === this._selectedIndex) {
const tabs = this._tabs.toArray();
for (let i = 0; i < tabs.length; i++) {
if (tabs[i].isActive) {
// Assign both to the `_indexToSelect` and `_selectedIndex` so we don't fire a changed
// event, otherwise the consumer may end up in an infinite loop in some edge cases like
// adding a tab within the `selectedIndexChange` event.
this._indexToSelect = this._selectedIndex = i;
break;
}
}
}

this._changeDetectorRef.markForCheck();
});
}

ngOnDestroy() {
this._tabs.destroy();
this._tabsSubscription.unsubscribe();
this._tabLabelSubscription.unsubscribe();
}

/* Event handlers */

_handleClick(tab:DashboardCardTab, index: number) {
if (!tab.disabled) this.selectedIndex = index;
}

/* Helpers */

/** Clamps the given index to the bounds of 0 and the tabs length. */
private _clampTabIndex(index: number | null): number {
// Note the `|| 0`, which ensures that values like NaN can't get through
// and which would otherwise throw the component into an infinite loop
// (since Math.max(NaN, 0) === NaN).
return Math.min(this._tabs.length - 1, Math.max(index || 0, 0));
}

private _createChangeEvent(index: number): DashboardCardTabChangeEvent {
return new DashboardCardTabChangeEvent(index, this._tabs && this._tabs.length ? this._tabs.toArray()[index] : undefined);
}
}
Loading