From beba09cd3b0d8fe485abc164f77b8889b479a133 Mon Sep 17 00:00:00 2001 From: luxiaobei Date: Fri, 28 May 2021 16:20:25 +0800 Subject: [PATCH 1/4] feat(nav): nav support responsive --- src/nav/examples/module.ts | 8 +- src/nav/examples/responsive/index.md | 4 + .../responsive/responsive.component.html | 20 +++ .../responsive/responsive.component.ts | 17 ++ src/nav/nav-link.directive.ts | 88 +++++++++- src/nav/nav.component.html | 41 +++++ src/nav/nav.component.ts | 162 ++++++++++++++++-- src/nav/nav.module.ts | 17 +- src/nav/styles/mixin.scss | 7 + src/nav/styles/nav.scss | 31 +++- 10 files changed, 361 insertions(+), 34 deletions(-) create mode 100644 src/nav/examples/responsive/index.md create mode 100644 src/nav/examples/responsive/responsive.component.html create mode 100644 src/nav/examples/responsive/responsive.component.ts create mode 100644 src/nav/nav.component.html diff --git a/src/nav/examples/module.ts b/src/nav/examples/module.ts index f768b25ed..4caf4253f 100644 --- a/src/nav/examples/module.ts +++ b/src/nav/examples/module.ts @@ -3,9 +3,12 @@ import { NgxTethysModule } from 'ngx-tethys'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + import { ThyNavFillExampleComponent } from './fill/fill.component'; import { ThyNavHorizontalExampleComponent } from './horizontal/horizontal.component'; import { ThyNavIconNavExampleComponent } from './icon-nav/icon-nav.component'; +import { ThyNavResponsiveExampleComponent } from './responsive/responsive.component'; import { ThyNavTypeExampleComponent } from './type/type.component'; import { ThyNavVerticalExampleComponent } from './vertical/vertical.component'; @@ -14,13 +17,14 @@ const COMPONENTS = [ ThyNavHorizontalExampleComponent, ThyNavIconNavExampleComponent, ThyNavTypeExampleComponent, - ThyNavVerticalExampleComponent + ThyNavVerticalExampleComponent, + ThyNavResponsiveExampleComponent ]; @NgModule({ declarations: COMPONENTS, entryComponents: COMPONENTS, - imports: [CommonModule, FormsModule, NgxTethysModule], + imports: [CommonModule, FormsModule, NgxTethysModule, RouterModule], exports: [], providers: COMPONENTS }) diff --git a/src/nav/examples/responsive/index.md b/src/nav/examples/responsive/index.md new file mode 100644 index 000000000..2cac8ef2a --- /dev/null +++ b/src/nav/examples/responsive/index.md @@ -0,0 +1,4 @@ +--- +order: 50 +title: 响应式 +--- diff --git a/src/nav/examples/responsive/responsive.component.html b/src/nav/examples/responsive/responsive.component.html new file mode 100644 index 000000000..233322def --- /dev/null +++ b/src/nav/examples/responsive/responsive.component.html @@ -0,0 +1,20 @@ + + 导航一 + 导航二 + 导航三 + 导航四 + 导航五 + 导航六 + 导航七 + 导航八 + 导航九 + + 导航十 + + 导航十一 + + 导航十二 + 导航十三 + +
导航十四
+
diff --git a/src/nav/examples/responsive/responsive.component.ts b/src/nav/examples/responsive/responsive.component.ts new file mode 100644 index 000000000..b86c3ce7c --- /dev/null +++ b/src/nav/examples/responsive/responsive.component.ts @@ -0,0 +1,17 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'thy-nav-responsive-example', + templateUrl: './responsive.component.html' +}) +export class ThyNavResponsiveExampleComponent implements OnInit { + activeIndex = 13; + + constructor() {} + + ngOnInit(): void {} + + select(value: number) { + this.activeIndex = value; + } +} diff --git a/src/nav/nav-link.directive.ts b/src/nav/nav-link.directive.ts index 2c552e201..6fd63f24c 100644 --- a/src/nav/nav-link.directive.ts +++ b/src/nav/nav-link.directive.ts @@ -1,20 +1,92 @@ -import { Component, Directive, ElementRef, Renderer2, Input, HostBinding } from '@angular/core'; -import { coerceBooleanProperty } from 'ngx-tethys/util'; +import { InputBoolean, MixinBase, mixinUnsubscribe } from 'ngx-tethys/core'; +import { takeUntil } from 'rxjs/operators'; + +import { + AfterViewInit, + ContentChildren, + Directive, + ElementRef, + HostBinding, + Input, + NgZone, + OnDestroy, + Optional, + QueryList, + Renderer2 +} from '@angular/core'; +import { RouterLinkActive } from '@angular/router'; export type ThyNavLink = '' | 'active'; @Directive({ selector: '[thyNavLink]' }) -export class ThyNavLinkDirective { +export class ThyNavLinkDirective extends mixinUnsubscribe(MixinBase) implements AfterViewInit, OnDestroy { + @HostBinding('class.active') @Input() - set thyNavLinkActive(active: string) { - this.navLinkActive = coerceBooleanProperty(active); - } - - @HostBinding('class.active') navLinkActive = false; + @InputBoolean() + thyNavLinkActive: boolean; @HostBinding('class.nav-link') navLinkClass = true; + @ContentChildren(ThyNavLinkDirective, { descendants: true }) links: QueryList; + + @ContentChildren(RouterLinkActive, { descendants: true }) routers: QueryList; + // @HostBinding('attr.href') navLinkHref = 'javascript:;'; + + public width = 0; + + public height = 0; + + public left = 0; + + public top = 0; + + public content: HTMLElement; + + public isActive: boolean; + + constructor( + public elementRef: ElementRef, + private renderer: Renderer2, + @Optional() private routerLinkActive: RouterLinkActive, + private ngZone: NgZone + ) { + super(); + } + + ngAfterViewInit() { + this.width = this.elementRef.nativeElement.offsetWidth; + this.height = this.elementRef.nativeElement.offsetHeight; + this.left = this.elementRef.nativeElement.offsetLeft; + this.top = this.elementRef.nativeElement.offsetTop; + this.content = this.elementRef.nativeElement.outerHTML; + + this.ngZone.onStable.pipe(takeUntil(this.ngUnsubscribe$)).subscribe(() => { + this.isActive = this.linkIsActive(); + }); + } + + linkIsActive() { + return ( + this.thyNavLinkActive || + (this.routerLinkActive && this.routerLinkActive.isActive) || + this.routers.some(router => router.isActive) || + this.links.some(item => item.thyNavLinkActive) + ); + } + + setNavLinkHidden(value: boolean) { + if (value) { + this.renderer.addClass(this.elementRef.nativeElement, 'nav-item-hidden'); + } else { + this.renderer.removeClass(this.elementRef.nativeElement, 'nav-item-hidden'); + } + } + + ngOnDestroy() { + this.ngUnsubscribe$.next(); + this.ngUnsubscribe$.complete(); + } } diff --git a/src/nav/nav.component.html b/src/nav/nav.component.html new file mode 100644 index 000000000..ea7b8bfee --- /dev/null +++ b/src/nav/nav.component.html @@ -0,0 +1,41 @@ + +
+ +
+ + + + + 更多 + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/src/nav/nav.component.ts b/src/nav/nav.component.ts index 4c1cb25a1..8750c2249 100644 --- a/src/nav/nav.component.ts +++ b/src/nav/nav.component.ts @@ -1,5 +1,27 @@ -import { Component, Directive, ElementRef, Renderer2, Input, HostBinding, OnInit } from '@angular/core'; -import { UpdateHostClassService } from 'ngx-tethys/core'; +import { InputBoolean, MixinBase, mixinUnsubscribe, UpdateHostClassService } from 'ngx-tethys/core'; +import { ThyPopover } from 'ngx-tethys/popover'; +import { merge } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; + +import { ViewportRuler } from '@angular/cdk/overlay'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChild, + ContentChildren, + ElementRef, + HostBinding, + Input, + NgZone, + OnDestroy, + OnInit, + QueryList, + TemplateRef +} from '@angular/core'; + +import { ThyNavLinkDirective } from './nav-link.directive'; export type ThyNavType = 'primary' | 'secondary' | 'thirdly' | 'secondary-divider'; export type ThyNavSize = '' | 'sm'; @@ -24,20 +46,31 @@ const navHorizontalClassesMap = { @Component({ selector: 'thy-nav', - template: ` - - `, + templateUrl: './nav.component.html', host: { class: 'thy-nav' }, - providers: [UpdateHostClassService] + providers: [UpdateHostClassService], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThyNavComponent implements OnInit { +export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnInit, AfterViewInit, OnDestroy { private _type: ThyNavType; private _size: ThyNavSize; private _horizontal: ThyNavHorizontal; private _initialized = false; + private wrapperSize: { height: number; width: number }; + + public hiddenItems: ThyNavLinkDirective[] = []; + + public moreActive: boolean; + + @ContentChildren(ThyNavLinkDirective, { descendants: true }) tabs: QueryList; + + @ContentChild('more') moreOperation: TemplateRef; + + @ContentChild('morePopover') morePopover: TemplateRef; + @Input() set thyType(type: ThyNavType) { this._type = type || 'primary'; @@ -62,19 +95,19 @@ export class ThyNavComponent implements OnInit { } } + @HostBinding('class.thy-nav--vertical') @Input() - set thyVertical(value: boolean) { - this._isVertical = value; - } + @InputBoolean() + thyVertical: boolean; + @HostBinding('class.thy-nav--fill') @Input() - set thyFill(value: boolean) { - this._isFill = value; - } - - @HostBinding('class.thy-nav--vertical') _isVertical = false; + @InputBoolean() + thyFill: boolean; - @HostBinding('class.thy-nav--fill') _isFill = false; + @Input() + @InputBoolean() + thyResponsive: boolean; private _updateClasses() { let classNames: string[] = []; @@ -90,7 +123,15 @@ export class ThyNavComponent implements OnInit { this.updateHostClass.updateClass(classNames); } - constructor(private updateHostClass: UpdateHostClassService, private elementRef: ElementRef) { + constructor( + private updateHostClass: UpdateHostClassService, + private elementRef: ElementRef, + private viewportRuler: ViewportRuler, + private ngZone: NgZone, + private changeDetectorRef: ChangeDetectorRef, + private popover: ThyPopover + ) { + super(); this.updateHostClass.initializeElement(elementRef.nativeElement); } @@ -98,4 +139,91 @@ export class ThyNavComponent implements OnInit { this._initialized = true; this._updateClasses(); } + + ngAfterViewInit() { + if (this.thyResponsive) { + this.ngZone.onStable.pipe(take(1)).subscribe(() => { + this.resetSizes(); + this.setHiddenItems(); + }); + + this.ngZone.onStable.pipe(takeUntil(this.ngUnsubscribe$)).subscribe(() => { + this.calculateMoreIsActive(); + }); + + merge(this.tabs.changes, this.viewportRuler.change(100)) + .pipe(takeUntil(this.ngUnsubscribe$)) + .subscribe(() => { + this.resetSizes(); + this.setHiddenItems(); + this.calculateMoreIsActive(); + }); + } + } + + private calculateMoreIsActive() { + this.moreActive = this.hiddenItems.some(item => { + return item.linkIsActive(); + }); + this.changeDetectorRef.detectChanges(); + } + + private setHiddenItems() { + this.moreActive = false; + const tabs = this.tabs.toArray(); + if (!tabs.length) { + this.hiddenItems = []; + return; + } + + const len = tabs.length; + let endIndex = len; + for (let i = len - 1; i >= 0; i -= 1) { + tabs[i].setNavLinkHidden(true); + if (this.thyVertical) { + if (tabs[i].top + tabs[i].height < this.wrapperSize.height + this.elementRef.nativeElement.offsetTop) { + endIndex = i; + break; + } + } else { + if (tabs[i].left + tabs[i].width < this.wrapperSize.width + this.elementRef.nativeElement.offsetLeft) { + endIndex = i; + break; + } + } + } + + const showItems = tabs.slice(0, endIndex); + (showItems || []).forEach(item => { + item.setNavLinkHidden(false); + }); + + this.hiddenItems = endIndex === len - 1 ? [] : [...tabs.slice(endIndex)]; + } + + private resetSizes() { + this.wrapperSize = { + height: this.elementRef.nativeElement.offsetHeight || 0, + width: this.elementRef.nativeElement.offsetWidth || 0 + }; + } + + openMore(event: Event, template: TemplateRef) { + this.popover.open(template, { + origin: event.currentTarget as HTMLElement, + hasBackdrop: true, + backdropClosable: true, + insideClosable: true, + placement: 'bottom' + }); + } + + navItemClick(item: ThyNavLinkDirective) { + item.elementRef.nativeElement.click(); + } + + ngOnDestroy() { + this.ngUnsubscribe$.next(); + this.ngUnsubscribe$.complete(); + } } diff --git a/src/nav/nav.module.ts b/src/nav/nav.module.ts index 003b732c3..947a30249 100644 --- a/src/nav/nav.module.ts +++ b/src/nav/nav.module.ts @@ -1,14 +1,19 @@ -import { NgModule } from '@angular/core'; +import { ThyActionMenuModule } from 'ngx-tethys/action-menu'; +import { ThyIconModule } from 'ngx-tethys/icon'; +import { ThyPopoverModule } from 'ngx-tethys/popover'; + import { CommonModule } from '@angular/common'; -import { ThyNavComponent } from './nav.component'; -import { ThyNavLinkDirective } from './nav-link.directive'; -import { ThyIconNavComponent } from './icon-nav/icon-nav.component'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + import { ThyIconNavLinkComponent } from './icon-nav/icon-nav-link.directive'; -import { ThyIconModule } from 'ngx-tethys/icon'; +import { ThyIconNavComponent } from './icon-nav/icon-nav.component'; +import { ThyNavLinkDirective } from './nav-link.directive'; +import { ThyNavComponent } from './nav.component'; @NgModule({ declarations: [ThyNavComponent, ThyNavLinkDirective, ThyIconNavComponent, ThyIconNavLinkComponent], - imports: [CommonModule, ThyIconModule], + imports: [CommonModule, ThyIconModule, ThyPopoverModule, ThyActionMenuModule, RouterModule], exports: [ThyNavComponent, ThyNavLinkDirective, ThyIconNavComponent, ThyIconNavLinkComponent] }) export class ThyNavModule {} diff --git a/src/nav/styles/mixin.scss b/src/nav/styles/mixin.scss index 0e4557d6c..0fbb3c31f 100644 --- a/src/nav/styles/mixin.scss +++ b/src/nav/styles/mixin.scss @@ -41,6 +41,13 @@ } } +@mixin nav-item-size($margin-right) { + margin-right: $margin-right; + &:last-child { + margin-right: 0; + } +} + @mixin nav-link-clear-margin-right() { &:last-child { margin-right: 0; diff --git a/src/nav/styles/nav.scss b/src/nav/styles/nav.scss index de2dc74ab..6ddf6c690 100644 --- a/src/nav/styles/nav.scss +++ b/src/nav/styles/nav.scss @@ -1,10 +1,11 @@ .thy-nav { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; padding-left: 0; margin-bottom: 0; list-style: none; + .thy-nav-item, .nav-link { display: block; text-align: center; @@ -14,6 +15,7 @@ border-bottom-style: solid; border-bottom-width: $nav-border-width; border-bottom-color: transparent; + flex: 0 auto; @include nav-link-variant($nav-link-color, $nav-link-hover-color, $nav-border-bottom); } @@ -25,6 +27,9 @@ min-width: $nav-link-primary-min-width; @include nav-link-variant($nav-link-primary-color, $nav-link-hover-color, $nav-border-bottom); } + .thy-nav-item { + @include nav-item-size($nav-link-primary-right); + } } .nav-secondary { @@ -103,3 +108,27 @@ } @import './icon-nav.scss'; + +.thy-nav-list { + display: flex; + flex-wrap: nowrap; + flex: 0 auto; + overflow: hidden; + .nav-item-hidden { + display: none; + } +} + +.thy-nav-list-popover { + .thy-nav-item { + display: none; + } + .nav-item-hidden { + display: block; + } +} + +.thy-nav-more-container { + flex: 1 !important; + flex-grow: 0 !important; +} From a74be5db42895b6163b12c8dbfdb977b3eeee61b Mon Sep 17 00:00:00 2001 From: luxiaobei Date: Mon, 31 May 2021 17:49:40 +0800 Subject: [PATCH 2/4] test(nav): add test about responsive --- src/nav/nav-link.directive.ts | 29 ++- src/nav/nav.component.html | 9 +- src/nav/nav.component.ts | 37 +-- src/nav/styles/nav.scss | 8 + src/nav/test/nav.component.spec.ts | 385 +++++++++++++++++++++++------ 5 files changed, 364 insertions(+), 104 deletions(-) diff --git a/src/nav/nav-link.directive.ts b/src/nav/nav-link.directive.ts index 6fd63f24c..f43b593cd 100644 --- a/src/nav/nav-link.directive.ts +++ b/src/nav/nav-link.directive.ts @@ -35,13 +35,17 @@ export class ThyNavLinkDirective extends mixinUnsubscribe(MixinBase) implements // @HostBinding('attr.href') navLinkHref = 'javascript:;'; - public width = 0; - - public height = 0; - - public left = 0; - - public top = 0; + public offset: { + width: number; + height: number; + left: number; + top: number; + } = { + width: 0, + height: 0, + left: 0, + top: 0 + }; public content: HTMLElement; @@ -57,10 +61,13 @@ export class ThyNavLinkDirective extends mixinUnsubscribe(MixinBase) implements } ngAfterViewInit() { - this.width = this.elementRef.nativeElement.offsetWidth; - this.height = this.elementRef.nativeElement.offsetHeight; - this.left = this.elementRef.nativeElement.offsetLeft; - this.top = this.elementRef.nativeElement.offsetTop; + this.offset = { + width: this.elementRef.nativeElement.offsetWidth, + height: this.elementRef.nativeElement.offsetHeight, + left: this.elementRef.nativeElement.offsetLeft, + top: this.elementRef.nativeElement.offsetTop + }; + this.content = this.elementRef.nativeElement.outerHTML; this.ngZone.onStable.pipe(takeUntil(this.ngUnsubscribe$)).subscribe(() => { diff --git a/src/nav/nav.component.html b/src/nav/nav.component.html index ea7b8bfee..74a6d4601 100644 --- a/src/nav/nav.component.html +++ b/src/nav/nav.component.html @@ -2,7 +2,6 @@
- - + diff --git a/src/nav/nav.component.ts b/src/nav/nav.component.ts index 8750c2249..974376b8d 100644 --- a/src/nav/nav.component.ts +++ b/src/nav/nav.component.ts @@ -5,6 +5,7 @@ import { take, takeUntil } from 'rxjs/operators'; import { ViewportRuler } from '@angular/cdk/overlay'; import { + AfterContentChecked, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, @@ -53,19 +54,24 @@ const navHorizontalClassesMap = { providers: [UpdateHostClassService], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnInit, AfterViewInit, OnDestroy { +export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnInit, AfterViewInit, AfterContentChecked, OnDestroy { private _type: ThyNavType; private _size: ThyNavSize; private _horizontal: ThyNavHorizontal; private _initialized = false; - private wrapperSize: { height: number; width: number }; + public wrapperOffset: { height: number; width: number; left: number; top: number } = { + height: 0, + width: 0, + left: 0, + top: 0 + }; public hiddenItems: ThyNavLinkDirective[] = []; public moreActive: boolean; - @ContentChildren(ThyNavLinkDirective, { descendants: true }) tabs: QueryList; + @ContentChildren(ThyNavLinkDirective, { descendants: true }) links: QueryList; @ContentChild('more') moreOperation: TemplateRef; @@ -147,11 +153,7 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn this.setHiddenItems(); }); - this.ngZone.onStable.pipe(takeUntil(this.ngUnsubscribe$)).subscribe(() => { - this.calculateMoreIsActive(); - }); - - merge(this.tabs.changes, this.viewportRuler.change(100)) + merge(this.links.changes, this.viewportRuler.change(100)) .pipe(takeUntil(this.ngUnsubscribe$)) .subscribe(() => { this.resetSizes(); @@ -161,6 +163,10 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn } } + ngAfterContentChecked() { + this.calculateMoreIsActive(); + } + private calculateMoreIsActive() { this.moreActive = this.hiddenItems.some(item => { return item.linkIsActive(); @@ -170,7 +176,7 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn private setHiddenItems() { this.moreActive = false; - const tabs = this.tabs.toArray(); + const tabs = this.links.toArray(); if (!tabs.length) { this.hiddenItems = []; return; @@ -181,12 +187,12 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn for (let i = len - 1; i >= 0; i -= 1) { tabs[i].setNavLinkHidden(true); if (this.thyVertical) { - if (tabs[i].top + tabs[i].height < this.wrapperSize.height + this.elementRef.nativeElement.offsetTop) { + if (tabs[i].offset.top + tabs[i].offset.height < this.wrapperOffset.height + this.wrapperOffset.top) { endIndex = i; break; } } else { - if (tabs[i].left + tabs[i].width < this.wrapperSize.width + this.elementRef.nativeElement.offsetLeft) { + if (tabs[i].offset.left + tabs[i].offset.width < this.wrapperOffset.width + this.wrapperOffset.left) { endIndex = i; break; } @@ -202,9 +208,11 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn } private resetSizes() { - this.wrapperSize = { + this.wrapperOffset = { height: this.elementRef.nativeElement.offsetHeight || 0, - width: this.elementRef.nativeElement.offsetWidth || 0 + width: this.elementRef.nativeElement.offsetWidth || 0, + left: this.elementRef.nativeElement.offsetLeft || 0, + top: this.elementRef.nativeElement.offsetTop || 0 }; } @@ -214,7 +222,8 @@ export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnIn hasBackdrop: true, backdropClosable: true, insideClosable: true, - placement: 'bottom' + placement: 'bottom', + panelClass: 'thy-nav-list-popover' }); } diff --git a/src/nav/styles/nav.scss b/src/nav/styles/nav.scss index 6ddf6c690..053d45eeb 100644 --- a/src/nav/styles/nav.scss +++ b/src/nav/styles/nav.scss @@ -126,6 +126,14 @@ .nav-item-hidden { display: block; } + .more-nav-link, + .more-nav-link * { + text-decoration: none; + color: $secondary; + &:hover { + color: $gray-800; + } + } } .thy-nav-more-container { diff --git a/src/nav/test/nav.component.spec.ts b/src/nav/test/nav.component.spec.ts index 1e3d188fe..330d19af9 100644 --- a/src/nav/test/nav.component.spec.ts +++ b/src/nav/test/nav.component.spec.ts @@ -1,12 +1,17 @@ -import { ThyNavLinkDirective } from './../nav-link.directive'; -import { ThyNavHorizontal } from './../nav.component'; -import { TestBed, ComponentFixture } from '@angular/core/testing'; -import { Component, OnInit } from '@angular/core'; -import { ThyNavModule } from '../nav.module'; -import { ThyIconModule } from '../../icon'; -import { injectDefaultSvgIconSet, bypassSanitizeProvider } from 'ngx-tethys/testing'; +import { bypassSanitizeProvider, dispatchFakeEvent, injectDefaultSvgIconSet } from 'ngx-tethys/testing'; + +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Component, DebugElement, ElementRef, inject, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { ThyNavComponent, ThyNavSize, ThyNavType } from '../nav.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ThyIconModule } from '../../icon'; +import { ThyNavLinkDirective } from '../nav-link.directive'; +import { ThyNavComponent, ThyNavHorizontal, ThyNavSize, ThyNavType } from '../nav.component'; +import { ThyNavModule } from '../nav.module'; const NAV_CLASS = `thy-nav`; const NAV_LINK_CLASS = `nav-link`; @@ -43,104 +48,330 @@ export class NavBasicComponent implements OnInit { ngOnInit(): void {} } +@Component({ + selector: 'app-nav-basic', + template: ` + + {{ item.name }} + + ` +}) +export class NavResponsiveComponent implements OnInit { + type: ThyNavType; + + size: ThyNavSize; + + isFill = false; + + isVertical = false; + + horizontal: ThyNavHorizontal; + + navLinks = [{ name: 'link1' }, { name: 'link2' }, { name: 'link3' }]; + + @ViewChildren(ThyNavLinkDirective) links: ThyNavLinkDirective[]; + + @ViewChildren(ThyNavLinkDirective, { read: ElementRef }) linksElement: QueryList; + + @ViewChild(ThyNavComponent) nav: ThyNavComponent; + + constructor() {} + + ngOnInit(): void {} +} + +@Component({ + selector: 'app-nav-basic', + template: `` +}) +export class NavRouteComponent {} + +const routes: Routes = [ + { + path: 'link2', + component: NavRouteComponent, + data: { + path: 'two' + } + } +]; + describe(`thy-nav`, () => { beforeEach(() => { TestBed.configureTestingModule({ - declarations: [NavBasicComponent], - imports: [ThyNavModule, ThyIconModule], + declarations: [NavBasicComponent, NavResponsiveComponent, NavRouteComponent], + imports: [ThyNavModule, ThyIconModule, NoopAnimationsModule, RouterTestingModule.withRoutes(routes)], providers: [bypassSanitizeProvider] }); TestBed.compileComponents(); injectDefaultSvgIconSet(); }); - let fixture: ComponentFixture; + describe('basic', () => { + let fixture: ComponentFixture; + beforeEach(() => { + fixture = TestBed.createComponent(NavBasicComponent); + fixture.detectChanges(); + }); - beforeEach(() => { - fixture = TestBed.createComponent(NavBasicComponent); - fixture.detectChanges(); - }); + it(`should get correct class for default nav`, () => { + const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); + const navElement: HTMLElement = navDebugElement.nativeElement; + expect(navDebugElement).toBeTruthy(); + expect(navElement).toBeTruthy(); + expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); + expect(navElement.classList.contains('nav-primary')).toEqual(true); + expect(navElement.classList.contains('custom-nav')).toEqual(true); + }); - it(`should get correct class for default nav`, () => { - const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); - const navElement: HTMLElement = navDebugElement.nativeElement; - expect(navDebugElement).toBeTruthy(); - expect(navElement).toBeTruthy(); - expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains('nav-primary')).toEqual(true); - expect(navElement.classList.contains('custom-nav')).toEqual(true); - }); + it(`should get correct nav links`, () => { + const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); + const links = navDebugElement.queryAll(By.directive(ThyNavLinkDirective)); + expect(links).toBeTruthy(); + expect(links.length).toEqual(2); + const activeLink: HTMLElement = links[0].nativeElement; + const link2: HTMLElement = links[1].nativeElement; + expect(activeLink.textContent).toContain('Link1'); + expect(activeLink.classList.contains(NAV_LINK_CLASS)).toEqual(true); + expect(activeLink.classList.contains('active')).toEqual(true); - it(`should get correct nav links`, () => { - const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); - const links = navDebugElement.queryAll(By.directive(ThyNavLinkDirective)); - expect(links).toBeTruthy(); - expect(links.length).toEqual(2); - const activeLink: HTMLElement = links[0].nativeElement; - const link2: HTMLElement = links[1].nativeElement; - expect(activeLink.textContent).toContain('Link1'); - expect(activeLink.classList.contains(NAV_LINK_CLASS)).toEqual(true); - expect(activeLink.classList.contains('active')).toEqual(true); - - expect(link2.textContent).toContain('Link2'); - expect(link2.classList.contains(NAV_LINK_CLASS)).toEqual(true); - expect(link2.classList.contains('active')).toEqual(false); - }); + expect(link2.textContent).toContain('Link2'); + expect(link2.classList.contains(NAV_LINK_CLASS)).toEqual(true); + expect(link2.classList.contains('active')).toEqual(false); + }); + + it(`should get correct class when input type`, () => { + ['primary', 'secondary', 'thirdly', 'secondary-divider'].forEach(type => { + fixture.debugElement.componentInstance.type = type; + fixture.detectChanges(); + const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); + const navElement: HTMLElement = navDebugElement.nativeElement; + expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); + expect(navElement.classList.contains(`nav-${type}`)).toEqual(true); + }); + }); - it(`should get correct class when input type`, () => { - ['primary', 'secondary', 'thirdly', 'secondary-divider'].forEach(type => { - fixture.debugElement.componentInstance.type = type; + it(`should get correct class when input size`, () => { + ['sm'].forEach(size => { + fixture.debugElement.componentInstance.size = size; + fixture.detectChanges(); + const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); + const navElement: HTMLElement = navDebugElement.nativeElement; + expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); + expect(navElement.classList.contains(`nav-${size}`)).toEqual(true); + }); + }); + + it(`should get correct class when is fill`, () => { + fixture.debugElement.componentInstance.isFill = true; fixture.detectChanges(); const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); const navElement: HTMLElement = navDebugElement.nativeElement; expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains(`nav-${type}`)).toEqual(true); + expect(navElement.classList.contains(`thy-nav--fill`)).toEqual(true); }); - }); - it(`should get correct class when input size`, () => { - ['sm'].forEach(size => { - fixture.debugElement.componentInstance.size = size; + it(`should get correct class when is vertical`, () => { + fixture.debugElement.componentInstance.isVertical = true; fixture.detectChanges(); const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); const navElement: HTMLElement = navDebugElement.nativeElement; expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains(`nav-${size}`)).toEqual(true); + expect(navElement.classList.contains(`thy-nav--vertical`)).toEqual(true); }); - }); - it(`should get correct class when is fill`, () => { - fixture.debugElement.componentInstance.isFill = true; - fixture.detectChanges(); - const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); - const navElement: HTMLElement = navDebugElement.nativeElement; - expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains(`thy-nav--fill`)).toEqual(true); - }); + it(`should get correct class when input thyHorizontal`, () => { + const navHorizontalClassesMap = { + left: '', + center: 'justify-content-center', + right: 'justify-content-end' + }; - it(`should get correct class when is vertical`, () => { - fixture.debugElement.componentInstance.isVertical = true; - fixture.detectChanges(); - const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); - const navElement: HTMLElement = navDebugElement.nativeElement; - expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains(`thy-nav--vertical`)).toEqual(true); + ['center', 'right'].forEach(item => { + fixture.debugElement.componentInstance.horizontal = item; + fixture.detectChanges(); + const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); + const navElement: HTMLElement = navDebugElement.nativeElement; + expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); + expect(navElement.classList.contains(navHorizontalClassesMap[item])).toEqual(true); + }); + }); }); - it(`should get correct class when input thyHorizontal`, () => { - const navHorizontalClassesMap = { - left: '', - center: 'justify-content-center', - right: 'justify-content-end' - }; + describe('responsive', () => { + let overlayContainer: OverlayContainer; + let fixture: ComponentFixture; - ['center', 'right'].forEach(item => { - fixture.debugElement.componentInstance.horizontal = item; - fixture.detectChanges(); - const navDebugElement = fixture.debugElement.query(By.directive(ThyNavComponent)); - const navElement: HTMLElement = navDebugElement.nativeElement; - expect(navElement.classList.contains(NAV_CLASS)).toEqual(true); - expect(navElement.classList.contains(navHorizontalClassesMap[item])).toEqual(true); + beforeEach(() => { + fixture = TestBed.createComponent(NavResponsiveComponent); + overlayContainer = TestBed.inject(OverlayContainer); }); + + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + + it('should show more when responsive and overflow in horizontal direction', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.detectChanges(); + + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeFalsy(); + expect(fixture.debugElement.queryAll(By.css('.nav-item-hidden')).length).toEqual(2); + })); + + it('should active moreBtn when hidden link is active', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.debugElement.componentInstance.navLinks = [{ name: 'link1' }, { name: 'link2', isActive: true }, { name: 'link3' }]; + fixture.detectChanges(); + + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeFalsy(); + expect(moreBtn.nativeElement.classList.contains('active')).toBeTruthy(); + })); + + it('should active moreBtn when hidden link router is active', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + const router: Router = TestBed.inject(Router); + router.initialNavigation(); + fixture.detectChanges(); + router.navigate(['.', 'link2']); + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeFalsy(); + expect(moreBtn.nativeElement.classList.contains('active')).toBeTruthy(); + })); + + it('should show more when responsive and overflow in vertical direction', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.debugElement.componentInstance.isVertical = true; + fixture.detectChanges(); + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeFalsy(); + expect(fixture.debugElement.queryAll(By.css('.nav-item-hidden')).length).toEqual(2); + })); + + it('should hidden moreBtn when has not navLinks', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.debugElement.componentInstance.navLinks = []; + + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeTruthy(); + expect(fixture.debugElement.queryAll(By.css('.test-link')).length).toEqual(0); + })); + + it('should show all navLinks when change navLinks', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.detectChanges(); + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + fixture.debugElement.componentInstance.navLinks = [...fixture.debugElement.componentInstance.navLinks, { name: 'link4' }]; + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + expect(moreBtn).toBeTruthy(); + expect(moreBtn.nativeElement.classList.contains('d-none')).toBeFalsy(); + expect(fixture.debugElement.queryAll(By.css('.test-link')).length).toEqual( + fixture.debugElement.componentInstance.navLinks.length + ); + })); + + it('should show hidden links when click moreBtn', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.detectChanges(); + + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + dispatchFakeEvent(moreBtn.nativeElement, 'click'); + + const popover = overlayContainer.getContainerElement().querySelector('thy-popover-container'); + expect(popover).toBeTruthy(); + expect(popover.querySelectorAll('.more-nav-link').length).toEqual(2); + })); + + it('should call item event when click navLink in more popover', fakeAsync(() => { + fixture.debugElement.componentInstance.responsive = true; + fixture.detectChanges(); + + spyLinksAndNavOffset(fixture.componentInstance.links, fixture.componentInstance.nav); + dispatchFakeEvent(window, 'resize'); + fixture.detectChanges(); + tick(300); + fixture.detectChanges(); + const moreBtn: DebugElement = fixture.debugElement.query(By.css('.thy-nav-more-container')); + dispatchFakeEvent(moreBtn.nativeElement, 'click'); + const popover = overlayContainer.getContainerElement().querySelector('thy-popover-container'); + const link = popover.querySelectorAll('.more-nav-link')[0]; + const linkSpy = spyOn(fixture.componentInstance.linksElement.toArray()[1].nativeElement, 'click'); + dispatchFakeEvent(link, 'click'); + expect(linkSpy).toHaveBeenCalled(); + })); }); }); + +function spyLinksAndNavOffset(links: ThyNavLinkDirective[], nav: ThyNavComponent) { + (links || []).forEach((link, index) => { + link.offset = { + width: 30, + height: 30, + left: 30 * index, + top: 30 * index + }; + }); + + spyOnProperty(nav['elementRef'].nativeElement, 'offsetWidth', 'get').and.returnValue(70); + spyOnProperty(nav['elementRef'].nativeElement, 'offsetHeight', 'get').and.returnValue(70); + spyOnProperty(nav['elementRef'].nativeElement, 'offsetLeft', 'get').and.returnValue(0); + spyOnProperty(nav['elementRef'].nativeElement, 'offsetTop', 'get').and.returnValue(0); +} From 7e5bef3df9f2fd2745cb7c4edd9c30647f924faf Mon Sep 17 00:00:00 2001 From: luxiaobei Date: Tue, 1 Jun 2021 15:40:45 +0800 Subject: [PATCH 3/4] fix(nav): fix generate import relative path when extends mixin class --- src/nav/nav-link.directive.ts | 6 ++++-- src/nav/nav.component.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/nav/nav-link.directive.ts b/src/nav/nav-link.directive.ts index f43b593cd..f4d03bf2e 100644 --- a/src/nav/nav-link.directive.ts +++ b/src/nav/nav-link.directive.ts @@ -1,4 +1,4 @@ -import { InputBoolean, MixinBase, mixinUnsubscribe } from 'ngx-tethys/core'; +import { Constructor, InputBoolean, MixinBase, mixinUnsubscribe, ThyUnsubscribe } from 'ngx-tethys/core'; import { takeUntil } from 'rxjs/operators'; import { @@ -18,10 +18,12 @@ import { RouterLinkActive } from '@angular/router'; export type ThyNavLink = '' | 'active'; +const _MixinBase: Constructor & typeof MixinBase = mixinUnsubscribe(MixinBase); + @Directive({ selector: '[thyNavLink]' }) -export class ThyNavLinkDirective extends mixinUnsubscribe(MixinBase) implements AfterViewInit, OnDestroy { +export class ThyNavLinkDirective extends _MixinBase implements AfterViewInit, OnDestroy { @HostBinding('class.active') @Input() @InputBoolean() diff --git a/src/nav/nav.component.ts b/src/nav/nav.component.ts index 974376b8d..f63bdab2f 100644 --- a/src/nav/nav.component.ts +++ b/src/nav/nav.component.ts @@ -1,4 +1,4 @@ -import { InputBoolean, MixinBase, mixinUnsubscribe, UpdateHostClassService } from 'ngx-tethys/core'; +import { Constructor, InputBoolean, MixinBase, mixinUnsubscribe, ThyUnsubscribe, UpdateHostClassService } from 'ngx-tethys/core'; import { ThyPopover } from 'ngx-tethys/popover'; import { merge } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; @@ -24,6 +24,8 @@ import { import { ThyNavLinkDirective } from './nav-link.directive'; +const _MixinBase: Constructor & typeof MixinBase = mixinUnsubscribe(MixinBase); + export type ThyNavType = 'primary' | 'secondary' | 'thirdly' | 'secondary-divider'; export type ThyNavSize = '' | 'sm'; export type ThyNavHorizontal = '' | 'left' | 'center' | 'right'; @@ -54,7 +56,7 @@ const navHorizontalClassesMap = { providers: [UpdateHostClassService], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThyNavComponent extends mixinUnsubscribe(MixinBase) implements OnInit, AfterViewInit, AfterContentChecked, OnDestroy { +export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit, AfterContentChecked, OnDestroy { private _type: ThyNavType; private _size: ThyNavSize; private _horizontal: ThyNavHorizontal; From 738ddb91e0ef0b96f791364c8388436c8b761ac5 Mon Sep 17 00:00:00 2001 From: luxiaobei Date: Mon, 12 Jul 2021 16:10:59 +0800 Subject: [PATCH 4/4] feat(nav): nav support responsive --- src/nav/nav-link.directive.ts | 16 ++++++++++------ src/nav/nav.component.ts | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/nav/nav-link.directive.ts b/src/nav/nav-link.directive.ts index f4d03bf2e..67e213255 100644 --- a/src/nav/nav-link.directive.ts +++ b/src/nav/nav-link.directive.ts @@ -63,12 +63,7 @@ export class ThyNavLinkDirective extends _MixinBase implements AfterViewInit, On } ngAfterViewInit() { - this.offset = { - width: this.elementRef.nativeElement.offsetWidth, - height: this.elementRef.nativeElement.offsetHeight, - left: this.elementRef.nativeElement.offsetLeft, - top: this.elementRef.nativeElement.offsetTop - }; + this.setOffset(); this.content = this.elementRef.nativeElement.outerHTML; @@ -77,6 +72,15 @@ export class ThyNavLinkDirective extends _MixinBase implements AfterViewInit, On }); } + setOffset() { + this.offset = { + width: this.elementRef.nativeElement.offsetWidth, + height: this.elementRef.nativeElement.offsetHeight, + left: this.elementRef.nativeElement.offsetLeft, + top: this.elementRef.nativeElement.offsetTop + }; + } + linkIsActive() { return ( this.thyNavLinkActive || diff --git a/src/nav/nav.component.ts b/src/nav/nav.component.ts index f63bdab2f..bdad5d40a 100644 --- a/src/nav/nav.component.ts +++ b/src/nav/nav.component.ts @@ -6,6 +6,7 @@ import { take, takeUntil } from 'rxjs/operators'; import { ViewportRuler } from '@angular/cdk/overlay'; import { AfterContentChecked, + AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, @@ -56,7 +57,7 @@ const navHorizontalClassesMap = { providers: [UpdateHostClassService], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit, AfterContentChecked, OnDestroy { +export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit, AfterContentInit, AfterContentChecked, OnDestroy { private _type: ThyNavType; private _size: ThyNavSize; private _horizontal: ThyNavHorizontal; @@ -151,7 +152,7 @@ export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit ngAfterViewInit() { if (this.thyResponsive) { this.ngZone.onStable.pipe(take(1)).subscribe(() => { - this.resetSizes(); + this.links.toArray().forEach(link => link.setOffset()); this.setHiddenItems(); }); @@ -165,6 +166,14 @@ export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit } } + ngAfterContentInit(): void { + if (this.thyResponsive) { + this.ngZone.onStable.pipe(take(1)).subscribe(() => { + this.resetSizes(); + }); + } + } + ngAfterContentChecked() { this.calculateMoreIsActive(); } @@ -201,6 +210,10 @@ export class ThyNavComponent extends _MixinBase implements OnInit, AfterViewInit } } + if (endIndex === len - 1) { + tabs[endIndex].setNavLinkHidden(false); + } + const showItems = tabs.slice(0, endIndex); (showItems || []).forEach(item => { item.setNavLinkHidden(false);