diff --git a/PROGRESS.md b/PROGRESS.md index 17fc2d1612c..4c0b8cdd4b1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -40,13 +40,13 @@ | datepicker | x | x | x | trotyl | - | | timepicker | x | x | x | trotyl | - | | calendar | √ | 100% | 100% | trotyl | √ | -| affix | x | x | x | cipchk | - | +| affix | √ | 100% | 100% | cipchk | √ | | transfer | √ | 100% | 100% | cipchk | x | | avatar | √ | 100% | 100% | cipchk | x | | list | √ | 100% | 100% | cipchk | x | | upload | √ | 100% | 100% | cipchk | x | -| anchor | x | x | x | cipchk | - | -| backtop | x | x | x | cipchk | - | +| anchor | √ | 100% | 100% | cipchk | √ | +| backtop | √ | 100% | 100% | cipchk | x | | divider | √ | 100% | 100% | cipchk | x | | treeselect | x | x | x | simplejason | - | | tree | x | x | x | simplejason | - | diff --git a/components/affix/affix.spec.ts b/components/affix/affix.spec.ts new file mode 100644 index 00000000000..54fc1abe45a --- /dev/null +++ b/components/affix/affix.spec.ts @@ -0,0 +1,499 @@ +import { Component, DebugElement, ViewChild, ViewEncapsulation } from '@angular/core'; +import { + discardPeriodicTasks, + fakeAsync, + tick, + ComponentFixture, + TestBed +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { NzScrollService } from '../core/scroll/nz-scroll.service'; + +import { NzAffixComponent } from './nz-affix.component'; +import { NzAffixModule } from './nz-affix.module'; + +interface Offset { + top: number; + left: number; + width: number; + height: number; +} + +interface Scroll { + top: number; + left: number; +} + +describe('affix', () => { + let scrollService: NzScrollService; + let fixture: ComponentFixture; + let context: TestAffixComponent; + let debugElement: DebugElement; + let component: NzAffixComponent; + let componentObject: NzAffixPageObject; + const defaultOffsetTop = 0; + const scrollEvent: Event = new Event('scroll'); + const startOffset = 10; + const handledEvents: Event[] = [ + scrollEvent, + new Event('resize'), + new Event('touchstart'), + new Event('touchmove'), + new Event('touchend'), + new Event('pageshow'), + new Event('load'), + ]; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [NzAffixModule], + declarations: [TestAffixComponent], + providers: [ + { + provide: NzScrollService, + useClass: NzScrollService + }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestAffixComponent); + context = fixture.componentInstance; + component = context.nzAffixComponent; + scrollService = TestBed.get(NzScrollService); + componentObject = new NzAffixPageObject(); + debugElement = fixture.debugElement; + componentObject.wrap().id = 'wrap'; + })); + + describe('[default]', () => { + it('recreate bug https://github.com/NG-ZORRO/ng-zorro-antd/issues/671', fakeAsync(() => { + const edge = defaultOffsetTop + startOffset; + setupInitialState(); + emitScroll(window, edge + 2); + componentObject.emitScroll(window, edge + 1); + componentObject.emitScroll(window, edge); + componentObject.emitScroll(window, edge - 1); + tick(100); + fixture.detectChanges(); + + expect(componentObject.wrap().offsetTop !== defaultOffsetTop).toBe(true); + + discardPeriodicTasks(); + })); + + it('wraps content with affix', () => { + expect(componentObject.content() === null).toBe(false); + }); + + describe('when scrolled within top offset', () => { + it('scrolls with the content', fakeAsync(() => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset - 1); + + expect(componentObject.wrap().offsetTop !== defaultOffsetTop).toBe(true); + + discardPeriodicTasks(); + })); + }); + + describe('when scrolled below the top offset', () => { + it('sticks to the top offset', fakeAsync(() => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset + 1); + expect(componentObject.wrap().offsetTop).toBe(defaultOffsetTop); + + discardPeriodicTasks(); + })); + + describe('when element gets shifted horizontally', () => { + it('adjusts left position accordingly to maintain natural position', fakeAsync(() => { + setupInitialState(); + componentObject.offsetTo(componentObject.elementRef(), { top: startOffset, left: 10, width: 100, height: 100 }); + emitScroll(window, defaultOffsetTop + startOffset + 1); + + expect(componentObject.wrap().offsetLeft).toBe(10); + + emitScroll(window, defaultOffsetTop + startOffset - 1); + componentObject.offsetTo(componentObject.elementRef(), { top: startOffset, left: 100, width: 100, height: 100 }); + emitScroll(window, defaultOffsetTop + startOffset + 1); + + expect(componentObject.wrap().offsetLeft).toBe(100); + + discardPeriodicTasks(); + })); + }); + + for (const event of handledEvents) { + it(`handles '${event.type}' event`, fakeAsync(() => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset + 1); + + expect(componentObject.wrap().offsetTop).toBe(defaultOffsetTop); + + discardPeriodicTasks(); + })); + } + }); + + it('shoule be re-adjust width when trigger resize', fakeAsync(() => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset - 1); + componentObject.emitEvent(window, new Event('resize')); + tick(20); + fixture.detectChanges(); + discardPeriodicTasks(); + })); + }); + + describe('[nzOffsetTop]', () => { + const offsetTop = 150; + const componentOffset = 160; + + beforeEach(() => { + context.newOffset = offsetTop; + }); + + describe('when scrolled within top offset', () => { + it('scrolls with the content', fakeAsync(() => { + setupInitialState({ offsetTop: offsetTop + 1 }); + emitScroll(window, 0); + + expect(componentObject.wrap().offsetTop !== offsetTop).toBe(true); + + discardPeriodicTasks(); + })); + }); + + describe('when scrolled below the top offset', () => { + it('sticks to the top offset', fakeAsync(() => { + setupInitialState({ offsetTop: offsetTop + 1 }); + emitScroll(window, 2); + + expect(componentObject.wrap().offsetTop).toBe(offsetTop); + + discardPeriodicTasks(); + })); + }); + + it('recreate bug https://github.com/NG-ZORRO/ng-zorro-antd/issues/868', fakeAsync(() => { + context.newOffset = offsetTop.toString(); + setupInitialState({ offsetTop: offsetTop + 1 }); + emitScroll(window, 2); + + expect(componentObject.wrap().offsetTop).toBe(offsetTop); + + discardPeriodicTasks(); + })); + }); + + describe('[nzOffsetBottom]', () => { + const offsetTop = 0; + let target: HTMLElement | Window; + + describe('with window', () => { + beforeEach(() => { + target = window; + context.fakeTarget = target; + context.newOffsetBottom = 10; + }); + describe('when scrolled below the bottom offset', () => { + it('sticks to the bottom offset', fakeAsync(() => { + setupInitialState(); + emitScroll(target, 5000); + const wrapEl = componentObject.wrap(); + expect(+wrapEl.style.bottom.replace('px', '')).toBe(0); + + discardPeriodicTasks(); + })); + }); + }); + + describe('with target', () => { + beforeEach(() => { + target = componentObject.target(); + context.fakeTarget = target; + context.newOffsetBottom = offsetTop; + }); + describe('when scrolled within bottom offset', () => { + it('scrolls with the content', fakeAsync(() => { + setupInitialState(); + emitScroll(target, 0); + const wrapEl = componentObject.wrap(); + expect(+wrapEl.style.bottom.replace('px', '')).toBeGreaterThan(0); + + discardPeriodicTasks(); + })); + }); + + describe('when scrolled below the bottom offset', () => { + it('sticks to the bottom offset', fakeAsync(() => { + setupInitialState(); + emitScroll(target, 5000); + const wrapEl = componentObject.wrap(); + expect(+wrapEl.style.bottom.replace('px', '')).toBe(0); + + discardPeriodicTasks(); + })); + }); + }); + }); + + describe('[nzTarget]', () => { + let target: HTMLElement; + + beforeEach(() => { + target = componentObject.target(); + context.fakeTarget = target; + }); + + describe('when window is scrolled', () => { + it('scrolls with the content', fakeAsync(() => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset + 1); + + expect(componentObject.elementRef().offsetTop !== defaultOffsetTop).toBe(true); + + discardPeriodicTasks(); + })); + }); + + describe('when custom target is scrolled within top offset', () => { + it('scrolls with the content', fakeAsync(() => { + setupInitialState(); + emitScroll(target, defaultOffsetTop + startOffset - 1); + + expect(componentObject.elementRef().offsetTop !== defaultOffsetTop).toBe(true); + + discardPeriodicTasks(); + })); + }); + + describe('when custom target is scrolled below the top offset', () => { + it('sticks to the top offset', fakeAsync(() => { + setupInitialState(); + emitScroll(target, defaultOffsetTop + startOffset + 1); + + expect(componentObject.elementRef().offsetTop !== defaultOffsetTop).toBe(true); + + discardPeriodicTasks(); + })); + }); + + it('should be re-register listener', () => { + spyOn(component, 'updatePosition'); + expect(component.updatePosition).not.toHaveBeenCalled(); + fixture.detectChanges(); + context.fakeTarget = window; + fixture.detectChanges(); + expect(component.updatePosition).toHaveBeenCalled(); + }); + }); + + describe('(nzChange)', () => { + let changeValue; + beforeEach(() => { + component.nzChange.subscribe((returnValue) => { + changeValue = returnValue; + }); + }); + + it(`emit true when is affixed`, fakeAsync((done) => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset + 1); + + expect(changeValue).toBe(true); + + discardPeriodicTasks(); + })); + + it(`emit false when is unaffixed`, fakeAsync((done) => { + setupInitialState(); + emitScroll(window, defaultOffsetTop + startOffset + 1); + emitScroll(window, defaultOffsetTop + startOffset - 1); + + expect(changeValue).toBe(false); + + discardPeriodicTasks(); + })); + }); + + it('should adjust the width when resize', fakeAsync(() => { + const offsetTop = 150; + context.newOffset = offsetTop; + setupInitialState({ offsetTop: offsetTop + 1 }); + emitScroll(window, 2); + componentObject.offsetYTo(componentObject.elementRef(), offsetTop + 2); + tick(20); + fixture.detectChanges(); + componentObject.emitEvent(window, new Event('resize')); + tick(20); + fixture.detectChanges(); + + expect(componentObject.wrap().offsetTop).toBe(offsetTop); + + discardPeriodicTasks(); + })); + + class NzAffixPageObject { + offsets: { [key: string]: Offset }; + scrolls: { [key: string]: Scroll }; + + constructor() { + spyOn(component, 'getOffset').and.callFake(this.getOffset.bind(this)); + spyOn(scrollService, 'getScroll').and.callFake(this.getScroll.bind(this)); + this.offsets = { 'undefined': { top: 10, left: 0, height: 0, width: 0 } }; + this.scrolls = { 'undefined': { top: 10, left: 0 } }; + } + + getScroll(el?: Element | Window, top: boolean = true): number { + const ret = this.scrolls[this.getKey(el)] || { top: 0, left: 0 }; + return top ? ret.top : ret.left; + } + + getOffset(el: Element): Offset { + return this.offsets[el.id] || { top: 10, left: 0, height: 100, width: 100 }; + } + + emitEvent(el: Element | Window, event: Event): void { + el.dispatchEvent(event); + } + + emitScroll(el: Element | Window, top: number, left: number = 0): void { + this.scrolls[this.getKey(el)] = { top, left }; + this.emitEvent((el || window), scrollEvent); + } + + offsetTo(el: Element, offset: Offset): void { + this.offsets[this.getKey(el)] = { + top: offset.top, + left: offset.left, + height: 100, + width: 100 + }; + } + + offsetYTo(el: Element, offsetTop: number): void { + this.offsetTo( + el, + { + top: offsetTop, + left: 0, + height: 100, + width: 100 + } + ); + } + + content(): HTMLElement { + return debugElement.query(By.css('#content')).nativeElement; + } + + elementRef(): HTMLElement { + return debugElement.query(By.css('nz-affix')).nativeElement; + } + + wrap(): HTMLElement { + return debugElement.query(By.css('div')).nativeElement; + } + + target(): HTMLElement { + return debugElement.query(By.css('#target')).nativeElement; + } + + private getKey(el: Element | Window): string { + let key: string; + if (el instanceof Window) { + key = 'window'; + } else { + key = (el && el.id) || 'window'; + } + + return key; + } + } + + function setupInitialState(options: { offsetTop?: number } = {}): void { + componentObject.offsetYTo(componentObject.elementRef(), options.offsetTop || startOffset); + // 20ms显示器的重绘频率 + tick(20); + fixture.detectChanges(); + componentObject.emitScroll(window, 0); + tick(20); + fixture.detectChanges(); + } + + function emitScroll(el: Element | Window, offset: number): void { + componentObject.emitScroll(el, offset); + tick(20); + fixture.detectChanges(); + } +}); + +describe('affix-extra', () => { + let fixture: ComponentFixture; + let context: TestAffixComponent; + let dl: DebugElement; + let component: NzAffixComponent; + let page: PageObject; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NzAffixModule], + declarations: [TestAffixComponent] + }).compileComponents(); + fixture = TestBed.createComponent(TestAffixComponent); + context = fixture.componentInstance; + component = context.nzAffixComponent; + dl = fixture.debugElement; + page = new PageObject(); + }); + it('#getOffset', () => { + const ret = fixture.componentInstance.nzAffixComponent.getOffset(fixture.debugElement.query(By.css('#affix')).nativeElement, window); + expect(ret).not.toBeUndefined(); + }); + it('with window when scrolled below the bottom offset', fakeAsync(() => { + const value = 10; + context.newOffsetBottom = value; + context.fakeTarget = window; + fixture.detectChanges(); + const el = dl.query(By.css('nz-affix')).nativeElement as HTMLElement; + spyOn(el, 'getBoundingClientRect').and.returnValue({ + top: 1000, + left: 5, + width: 200, + height: 20 + }); + window.dispatchEvent(new Event('scroll')); + tick(30); + fixture.detectChanges(); + window.dispatchEvent(new Event('scroll')); + tick(30); + fixture.detectChanges(); + const ret = +(el.querySelector('.ant-affix') as HTMLElement).style.bottom.replace('px', ''); + expect(ret).toBe(value); + })); + class PageObject { + + } +}); + +@Component({ + template: ` + + + +
+ `, + styleUrls: [ './style/index.less' ], + encapsulation: ViewEncapsulation.None +}) +class TestAffixComponent { + @ViewChild(NzAffixComponent) + nzAffixComponent: NzAffixComponent; + fakeTarget: Element | Window = null; + newOffset: {}; + newOffsetBottom: {}; +} diff --git a/components/affix/demo/basic.ts b/components/affix/demo/basic.ts index dfd5ee0ec54..24076c753bc 100644 --- a/components/affix/demo/basic.ts +++ b/components/affix/demo/basic.ts @@ -1,27 +1,20 @@ -import { Component, ViewEncapsulation } from '@angular/core'; +import { Component } from '@angular/core'; @Component({ selector : 'nz-demo-affix-basic', - encapsulation: ViewEncapsulation.None, template : ` - - - - `, - styles : [ ` - #components-affix-demo-target .scrollable-container { - height: 100px; - overflow-y: scroll; - } - - #components-affix-demo-target .background { - padding-top: 60px; - height: 300px; - background-image: url('https://zos.alipayobjects.com/rmsportal/RmjwQiJorKyobvI.jpg'); - } - ` ] + + + +
+ + + + ` }) export class NzDemoAffixBasicComponent { } diff --git a/components/affix/demo/target.md b/components/affix/demo/target.md index 11016ff7173..00d009f55b2 100755 --- a/components/affix/demo/target.md +++ b/components/affix/demo/target.md @@ -7,8 +7,8 @@ title: ## zh-CN -用 `target` 设置 `Affix` 需要监听其滚动事件的元素,默认为 `window`。 +用 `nzTarget` 设置 `nz-affix` 需要监听其滚动事件的元素,默认为 `window`。 ## en-US -Set a `target` for 'Affix', which is listen to scroll event of target element (default is `window`). +Set a `nzTarget` for 'nz-affix', which is listen to scroll event of target element (default is `window`). diff --git a/components/affix/doc/index.en-US.md b/components/affix/doc/index.en-US.md index b450bafd50e..cde7fb2374c 100755 --- a/components/affix/doc/index.en-US.md +++ b/components/affix/doc/index.en-US.md @@ -16,15 +16,15 @@ Please note that Affix should not cover other content on the page, especially wh | Property | Description | Type | Default | | -------- | ----------- | ---- | ------- | -| offsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | -| offsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | -| target | specifies the scrollable area dom node | () => HTMLElement | () => window | -| onChange | Callback for when affix state is changed | Function(affixed) | - | +| nzOffsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | +| nzOffsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | +| nzTarget | specifies the scrollable area dom node | `HTMLElement` | `window` | +| nzChange | Callback for when affix state is changed | Function(affixed) | - | -**Note:** Children of `Affix` can not be `position: absolute`, but you can set `Affix` as `position: absolute`: +**Note:** Children of `nz-affix` can not be `position: absolute`, but you can set `nz-affix` as `position: absolute`: ```jsx - + ... - + ``` diff --git a/components/affix/doc/index.zh-CN.md b/components/affix/doc/index.zh-CN.md index ec1c9984084..0d08a250f6e 100755 --- a/components/affix/doc/index.zh-CN.md +++ b/components/affix/doc/index.zh-CN.md @@ -17,15 +17,15 @@ title: Affix | 成员 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | -| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | -| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | -| target | 设置 `Affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | () => HTMLElement | () => window | -| onChange | 固定状态改变时触发的回调函数 | Function(affixed) | 无 | +| nzOffsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | +| nzOffsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | +| nzTarget | 设置 `nz-affix` 需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | `HTMLElement` | `window` | +| nzChange | 固定状态改变时触发的回调函数 | Function(affixed) | 无 | -**注意:**`Affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `Affix` 为绝对定位: +**注意:**`nz-affix` 内的元素不要使用绝对定位,如需要绝对定位的效果,可以直接设置 `nz-affix` 为绝对定位: ```jsx - + ... - + ``` diff --git a/components/affix/index.ts b/components/affix/index.ts new file mode 100644 index 00000000000..7e1a213e3ea --- /dev/null +++ b/components/affix/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/components/affix/nz-affix.component.ts b/components/affix/nz-affix.component.ts index 9a3d1411e91..82235d81e81 100644 --- a/components/affix/nz-affix.component.ts +++ b/components/affix/nz-affix.component.ts @@ -1,147 +1,235 @@ +// tslint:disable:no-any import { - AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, - OnChanges, OnDestroy, OnInit, Output, SimpleChange, SimpleChanges, - ViewChild + ViewChild, } from '@angular/core'; -import { Subscription } from 'rxjs/Subscription'; -import { fromEvent } from 'rxjs/observable/fromEvent'; -import { distinctUntilChanged } from 'rxjs/operators/distinctUntilChanged'; -import { throttleTime } from 'rxjs/operators/throttleTime'; import { NzScrollService } from '../core/scroll/nz-scroll.service'; +import { shallowEqual } from '../core/util/check'; +import { toNumber } from '../core/util/convert'; +import { throttleByAnimationFrameDecorator } from '../core/util/throttleByAnimationFrame'; @Component({ - selector : 'nz-affix', - preserveWhitespaces: false, - template : ` -
- -
`, - host : { - 'style': 'display:block' - } + selector : 'nz-affix', + template : `
`, + changeDetection: ChangeDetectionStrategy.OnPush }) -export class NzAffixComponent implements OnChanges, OnInit, OnDestroy, AfterViewInit { - - private didScroll = false; - private scrollTime: number = null; - private scroll$: Subscription = null; - private scrollWinInTarget$: Subscription = null; +export class NzAffixComponent implements OnInit, OnDestroy { + + private timeout: any; + private events = [ + 'resize', + 'scroll', + 'touchstart', + 'touchmove', + 'touchend', + 'pageshow', + 'load', + ]; + private affixStyle: any; + private placeholderStyle: any; @ViewChild('wrap') private wrap: ElementRef; - // 缓存固定状态 - private fixed = false; - // 原始位置 - private orgOffset: { top: number, left: number }; + private _target: Element | Window = window; @Input() - nzTarget: Element; + set nzTarget(value: Element | Window) { + this.clearEventListeners(); + this._target = value || window; + this.setTargetEventListeners(); + this.updatePosition({}); + } - @Input() nzOffsetTop = 0; + private _offsetTop: number; + @Input() + set nzOffsetTop(value: number) { + if (typeof value === 'undefined') return; + this._offsetTop = toNumber(value, null); + } + get nzOffsetTop(): number { + return this._offsetTop; + } - @Input() nzOffsetBottom = 0; + private _offsetBottom: number; + @Input() + set nzOffsetBottom(value: number) { + if (typeof value === 'undefined') return; + this._offsetBottom = toNumber(value, null); + } @Output() nzChange: EventEmitter = new EventEmitter(); - constructor(private scrollSrv: NzScrollService, private _el: ElementRef) { + constructor(private scrollSrv: NzScrollService, private _el: ElementRef, private cd: ChangeDetectorRef) { } + + ngOnInit(): void { + this.timeout = setTimeout(() => { + this.setTargetEventListeners(); + this.updatePosition({}); + }); } - ngOnChanges(changes: { [P in keyof this]?: SimpleChange } & SimpleChanges): void { - if (changes.nzTarget) { - this.registerScrollEvent(); - } + private setTargetEventListeners(): void { + this.clearEventListeners(); + this.events.forEach((eventName: string) => { + this._target.addEventListener(eventName, this.updatePosition, false); + }); } - ngOnInit(): void { - if (!this.nzTarget) { - this.registerScrollEvent(); - } + private clearEventListeners(): void { + this.events.forEach(eventName => { + this._target.removeEventListener(eventName, this.updatePosition, false); + }); } - ngAfterViewInit(): void { - this.orgOffset = null; - this.fixed = false; + ngOnDestroy(): void { + this.clearEventListeners(); + clearTimeout(this.timeout); + (this.updatePosition as any).cancel(); } - private reCalculate(): this { - const elOffset = this.scrollSrv.getOffset(this._el.nativeElement); - this.orgOffset = { - top : elOffset.top + this.scrollSrv.getScroll(this.nzTarget), - left: elOffset.left + this.scrollSrv.getScroll(this.nzTarget, false) - }; + private getTargetRect(target: Element | Window | null): ClientRect { + return target !== window ? + (target as HTMLElement).getBoundingClientRect() : + { top: 0, left: 0, bottom: 0 } as ClientRect; + } - return this; + /** @private */ + getOffset(element: Element, target: Element | Window | null): { + top: number; + left: number; + width: number; + height: number; + } { + const elemRect = element.getBoundingClientRect(); + const targetRect = this.getTargetRect(target); + + const scrollTop = this.scrollSrv.getScroll(target, true); + const scrollLeft = this.scrollSrv.getScroll(target, false); + + const docElem = window.document.body; + const clientTop = docElem.clientTop || 0; + const clientLeft = docElem.clientLeft || 0; + + return { + top: elemRect.top - targetRect.top + scrollTop - clientTop, + left: elemRect.left - targetRect.left + scrollLeft - clientLeft, + width: elemRect.width, + height: elemRect.height + }; } - private process(): void { - if (!this.orgOffset) { - this.reCalculate(); - } - const containerScrollTop = this.scrollSrv.getScroll(this.nzTarget); - const fixTop = this.nzTarget ? this.scrollSrv.getOffset(this.nzTarget).top : 0; - const hasFixed = this.orgOffset.top - fixTop - containerScrollTop - this.nzOffsetTop <= 0; - if (this.fixed === hasFixed) { - return; - } + private genStyle(affixStyle: {}): string { + if (affixStyle == null) return ''; + return Object.keys(affixStyle).map(key => { + const val = affixStyle[key]; + return `${key}:${typeof val === 'string' ? val : val + 'px'}`; + }).join(';'); + } - const wrapEl = this.wrap.nativeElement; - wrapEl.classList[ hasFixed ? 'add' : 'remove' ]('ant-affix'); - if (hasFixed) { - wrapEl.style.cssText = `top:${((+this.nzOffsetTop) + (+fixTop))}px;left:${this.orgOffset.left}px`; + private setAffixStyle(e: any, affixStyle: {}): void { + const originalAffixStyle = this.affixStyle; + const isWindow = this._target === window; + if (e.type === 'scroll' && originalAffixStyle && affixStyle && isWindow) return; + if (shallowEqual(originalAffixStyle, affixStyle)) return; + + const fixed = !!affixStyle; + const wrapEl = this.wrap.nativeElement as HTMLElement; + wrapEl.style.cssText = this.genStyle(affixStyle); + this.affixStyle = affixStyle; + const cls = 'ant-affix'; + if (fixed) { + wrapEl.classList.add(cls); } else { - wrapEl.style.cssText = ``; + wrapEl.classList.remove(cls); } - this.fixed = hasFixed; - this.nzChange.emit(hasFixed); + if ((affixStyle && !originalAffixStyle) || (!affixStyle && originalAffixStyle)) { + this.nzChange.emit(fixed); + } } - private removeListen(): void { - if (this.scrollTime) { - clearTimeout(this.scrollTime); - } - if (this.scroll$) { - this.scroll$.unsubscribe(); - } - if (this.scrollWinInTarget$) { - this.scrollWinInTarget$.unsubscribe(); - } + private setPlaceholderStyle(placeholderStyle: {}): void { + const originalPlaceholderStyle = this.placeholderStyle; + if (shallowEqual(placeholderStyle, originalPlaceholderStyle)) return; + (this._el.nativeElement as HTMLElement).style.cssText = this.genStyle(placeholderStyle); + this.placeholderStyle = placeholderStyle; } - private registerScrollEvent(): void { - this.removeListen(); - this.reCalculate().process(); - // TODO: should refactor this logic - this.scrollTime = window.setInterval(() => { - if (this.didScroll) { - this.didScroll = false; - this.process(); - } - }, 100); - this.scroll$ = fromEvent(this.nzTarget || window, 'scroll') - .subscribe(() => this.didScroll = true); - - if (this.nzTarget) { - // 当 window 滚动位发生变动时,需要重新计算滚动容器 - this.scrollWinInTarget$ = fromEvent(window, 'scroll').pipe(throttleTime(50), distinctUntilChanged()) - .subscribe(e => { - this.orgOffset = null; - this.fixed = false; + @throttleByAnimationFrameDecorator() + updatePosition(e: any): void { + const targetNode = this._target; + // Backwards support + let offsetTop = this.nzOffsetTop; + const scrollTop = this.scrollSrv.getScroll(targetNode, true); + const affixNode = this._el.nativeElement as HTMLElement; + const elemOffset = this.getOffset(affixNode, targetNode); + const elemSize = { + width: affixNode.offsetWidth, + height: affixNode.offsetHeight, + }; + const offsetMode = { + top: false, + bottom: false, + }; + // Default to `offsetTop=0`. + if (typeof offsetTop !== 'number' && typeof this._offsetBottom !== 'number') { + offsetMode.top = true; + offsetTop = 0; + } else { + offsetMode.top = typeof offsetTop === 'number'; + offsetMode.bottom = typeof this._offsetBottom === 'number'; + } + const targetRect = this.getTargetRect(targetNode); + const targetInnerHeight = + (targetNode as Window).innerHeight || (targetNode as HTMLElement).clientHeight; + if (scrollTop > elemOffset.top - (offsetTop as number) && offsetMode.top) { + const width = elemOffset.width; + const top = targetRect.top + (offsetTop as number); + this.setAffixStyle(e, { + position: 'fixed', + top, + left: targetRect.left + elemOffset.left, + maxHeight: `calc(100vh - ${top}px)`, + width, + }); + this.setPlaceholderStyle({ + width, + height: elemSize.height, + }); + } else if ( + scrollTop < elemOffset.top + elemSize.height + (this._offsetBottom as number) - targetInnerHeight && + offsetMode.bottom + ) { + const targetBottomOffet = targetNode === window ? 0 : (window.innerHeight - targetRect.bottom); + const width = elemOffset.width; + this.setAffixStyle(e, { + position: 'fixed', + bottom: targetBottomOffet + (this._offsetBottom as number), + left: targetRect.left + elemOffset.left, + width, }); + this.setPlaceholderStyle({ + width, + height: elemOffset.height, + }); + } else { + if (e.type === 'resize' && this.affixStyle && this.affixStyle.position === 'fixed' && affixNode.offsetWidth) { + this.setAffixStyle(e, { ...this.affixStyle, width: affixNode.offsetWidth }); + } else { + this.setAffixStyle(e, null); + } + this.setPlaceholderStyle(null); } } - ngOnDestroy(): void { - this.removeListen(); - } - } diff --git a/components/affix/public-api.ts b/components/affix/public-api.ts new file mode 100644 index 00000000000..917d64233ec --- /dev/null +++ b/components/affix/public-api.ts @@ -0,0 +1,2 @@ +export * from './nz-affix.component'; +export * from './nz-affix.module'; diff --git a/components/affix/style/index.less b/components/affix/style/index.less index 7aa9e374409..25376579c50 100644 --- a/components/affix/style/index.less +++ b/components/affix/style/index.less @@ -1,7 +1,10 @@ @import "../../style/themes/default"; +nz-affix { + display: block; +} + .@{ant-prefix}-affix { position: fixed; z-index: @zindex-affix; - overflow: auto; } diff --git a/components/anchor/anchor.spec.ts b/components/anchor/anchor.spec.ts new file mode 100644 index 00000000000..73247dd2843 --- /dev/null +++ b/components/anchor/anchor.spec.ts @@ -0,0 +1,257 @@ +// tslint:disable +import { fakeAsync, tick, TestBed, ComponentFixture, async } from '@angular/core/testing'; +import { Component, DebugElement, ViewChild } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { NzAnchorModule } from './nz-anchor.module'; +import { NzAnchorComponent } from './nz-anchor.component'; +import { NzAnchorLinkComponent } from './nz-anchor-link.component'; +import { NzScrollService } from '../core/scroll/nz-scroll.service'; + +const throttleTime = 51; +describe('anchor', () => { + let fixture: ComponentFixture; + let dl: DebugElement; + let context: TestComponent; + let page: PageObject; + let srv: NzScrollService; + beforeEach(() => { + const i = TestBed.configureTestingModule({ + imports: [ NzAnchorModule ], + declarations: [ TestComponent ] + }); + fixture = TestBed.createComponent(TestComponent); + dl = fixture.debugElement; + context = fixture.componentInstance; + fixture.detectChanges(); + page = new PageObject(); + spyOn(context, '_scroll'); + srv = i.get(NzScrollService); + }); + afterEach(() => context.comp.ngOnDestroy()); + + describe('[default]', () => { + it(`should scolling to target via click a link`, () => { + spyOn(srv, 'scrollTo').and.callFake(( + containerEl: Element | Window, + targetTopValue: number = 0, + easing?: any, + callback?: () => void + ) => { + callback(); + }); + expect(context._scroll).not.toHaveBeenCalled(); + page.to('#何时使用'); + expect(context._scroll).toHaveBeenCalled(); + }); + + it('should hava remove listen when the component is destroyed', () => { + expect(context.comp.scroll$.closed).toBeFalsy(); + context.comp.ngOnDestroy(); + fixture.detectChanges(); + expect(context.comp.scroll$.closed).toBeTruthy(); + }); + + it('should actived when scrolling to the anchor', (done: () => void) => { + expect(context._scroll).not.toHaveBeenCalled(); + page.scrollTo(); + setTimeout(() => { + const inkNode = page.getEl('.ant-anchor-ink-ball'); + expect(+inkNode.style.top.replace('px', '')).toBeGreaterThan(0); + expect(context._scroll).toHaveBeenCalled(); + done(); + }, throttleTime); + }); + + it(`won't scolling when is not exists link`, () => { + spyOn(srv, 'getScroll'); + expect(context._scroll).not.toHaveBeenCalled(); + expect(srv.getScroll).not.toHaveBeenCalled(); + page.to('#invalid'); + expect(srv.getScroll).not.toHaveBeenCalled(); + }); + + it(`won't scolling when is invalid link`, () => { + spyOn(srv, 'getScroll'); + expect(context._scroll).not.toHaveBeenCalled(); + expect(srv.getScroll).not.toHaveBeenCalled(); + page.to('invalidLink'); + expect(srv.getScroll).not.toHaveBeenCalled(); + }); + + it(`supports complete href link (e.g. http://www.example.com/#id)`, () => { + spyOn(srv, 'getScroll'); + expect(context._scroll).not.toHaveBeenCalled(); + expect(srv.getScroll).not.toHaveBeenCalled(); + page.getEl('.mock-complete').click(); + fixture.detectChanges(); + expect(srv.getScroll).not.toHaveBeenCalled(); + }); + + it(`should priorities most recently`, (done: () => void) => { + expect(context._scroll).not.toHaveBeenCalled(); + page.scrollTo('#parallel1'); + setTimeout(() => { + expect(context._scroll).toHaveBeenCalled(); + done(); + }, throttleTime); + }); + }); + + describe('property', () => { + describe('[nzAffix]', () => { + it(`is [true]`, () => { + const linkList = dl.queryAll(By.css('nz-affix')); + expect(linkList.length).toBe(1); + }); + it(`is [false]`, () => { + let linkList = dl.queryAll(By.css('nz-affix')); + expect(linkList.length).toBe(1); + context.nzAffix = false; + fixture.detectChanges(); + linkList = dl.queryAll(By.css('nz-affix')); + expect(linkList.length).toBe(0); + }); + }); + + describe('[nzOffsetTop]', () => { + it('should be using "calc" method calculate max-height', () => { + const wrapperEl = dl.query(By.css('.ant-anchor-wrapper')); + expect(wrapperEl.styles['max-height']).toContain('calc('); + }); + }); + + describe('[nzShowInkInFixed]', () => { + beforeEach(() => { + context.nzAffix = false; + fixture.detectChanges(); + }); + it('should be show ink when [false]', () => { + context.nzShowInkInFixed = false; + fixture.detectChanges(); + scrollTo(); + expect(dl.query(By.css('.fixed')) == null).toBe(false); + }); + it('should be hide ink when [true]', () => { + context.nzShowInkInFixed = true; + fixture.detectChanges(); + scrollTo(); + expect(dl.query(By.css('.fixed')) == null).toBe(true); + }); + }); + + it('(nzClick)', () => { + spyOn(context, '_click'); + expect(context._click).not.toHaveBeenCalled(); + const linkList = dl.queryAll(By.css('.ant-anchor-link-title')); + expect(linkList.length).toBeGreaterThan(0); + (linkList[0].nativeElement as HTMLLinkElement).click(); + fixture.detectChanges(); + expect(context._click).toHaveBeenCalled(); + }); + }); + + describe('link', () => { + it(`should show custom template of [nzTemplate]`, () => { + expect(dl.query(By.css('.nzTemplate-title')) != null).toBe(true); + }); + it(`should show custom template of [nzTitle]`, () => { + expect(dl.query(By.css('.nzTitle-title')) != null).toBe(true); + }); + }); + + describe('**boundary**', () => { + it('#getOffsetTop', (done: () => void) => { + const el1 = document.getElementById('何时使用'); + spyOn(el1, 'getClientRects').and.returnValue([]); + const el2 = document.getElementById('parallel1'); + spyOn(el2, 'getBoundingClientRect').and.returnValue({ + top: 0 + }); + expect(context._scroll).not.toHaveBeenCalled(); + page.scrollTo(); + setTimeout(() => { + expect(context._scroll).toHaveBeenCalled(); + done(); + }, throttleTime); + }); + }); + + class PageObject { + getEl(cls: string): HTMLElement { + const el = dl.query(By.css(cls)); + expect(el).not.toBeNull(); + return el.nativeElement as HTMLElement; + } + to(href: string = '#何时使用'): this { + this.getEl(`nz-affix [href="${href}"]`).click(); + fixture.detectChanges(); + return this; + } + scrollTo(href: string = '#何时使用'): this { + const toNode = dl.query(By.css(href)); + (toNode.nativeElement as HTMLElement).scrollIntoView(); + fixture.detectChanges(); + return this; + } + } + +}); + +@Component({ + template: ` + + + + + + tpl + + + + + + + tpl-title + + + + + + + + + +

+
+

+
+

+
+

+
+ + + + + + +

parallel1

parallel2

+
+ ` +}) +export class TestComponent { + @ViewChild(NzAnchorComponent) comp: NzAnchorComponent; + nzAffix = true; + nzBounds = 5; + nzOffsetTop = 0; + nzShowInkInFixed = false; + nzTarget = null; + _click() {} + _scroll() {} +} diff --git a/components/anchor/demo/basic.ts b/components/anchor/demo/basic.ts index 80fa02edca0..3bbe1905578 100755 --- a/components/anchor/demo/basic.ts +++ b/components/anchor/demo/basic.ts @@ -3,24 +3,14 @@ import { Component } from '@angular/core'; @Component({ selector: 'nz-demo-anchor-basic', template: ` - - - - - - - - - - - `, - styles: [ - ` - :host ::ng-deep nz-anchor { - display: block; - width: 250px; - } - ` - ] + + + + + + + + + ` }) export class NzDemoAnchorBasicComponent { } diff --git a/components/anchor/demo/static.ts b/components/anchor/demo/static.ts index 4aad0b55c90..56d175b76f0 100644 --- a/components/anchor/demo/static.ts +++ b/components/anchor/demo/static.ts @@ -4,7 +4,7 @@ import { Component, ViewEncapsulation } from '@angular/core'; selector : 'nz-demo-anchor-static', encapsulation: ViewEncapsulation.None, template : ` - + @@ -12,12 +12,7 @@ import { Component, ViewEncapsulation } from '@angular/core'; - `, - styles : [ ` - .code-box-demo .ant-affix { - z-index: 11; - }` - ] + ` }) export class NzDemoAnchorStaticComponent { } diff --git a/components/anchor/doc/index.en-US.md b/components/anchor/doc/index.en-US.md index 4ee5e28b739..4b39369a30b 100755 --- a/components/anchor/doc/index.en-US.md +++ b/components/anchor/doc/index.en-US.md @@ -16,15 +16,17 @@ For displaying anchor hyperlinks on page and jumping between them. | Property | Description | Type | Default | | -------- | ----------- | ---- | ------- | -| affix | Fixed mode of Anchor | boolean | false | -| bounds | Bounding distance of anchor area | number | 5(px) | -| offsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | -| offsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | -| showInkInFixed | Whether show ink-balls in Fixed mode | boolean | false | +| nzAffix | Fixed mode of Anchor | boolean | true | +| nzBounds | Bounding distance of anchor area | number | 5(px) | +| nzOffsetBottom | Pixels to offset from bottom when calculating position of scroll | number | - | +| nzOffsetTop | Pixels to offset from top when calculating position of scroll | number | 0 | +| nzShowInkInFixed | Whether show ink-balls in Fixed mode | boolean | false | +| nzClick | Click of Anchor item | EventEmitter | - | +| nzScroll | The scroll function that is triggered when scrolling to an anchor. | EventEmitter | - | ### Link Props | Property | Description | Type | Default | | -------- | ----------- | ---- | ------- | -| href | target of hyperlink | string | | -| title | content of hyperlink | string丨ReactNode | | +| nzHref | target of hyperlink | string | | +| nzTitle | content of hyperlink | string丨TemplateRef | | diff --git a/components/anchor/doc/index.zh-CN.md b/components/anchor/doc/index.zh-CN.md index def54283bd8..cff7d9844d3 100755 --- a/components/anchor/doc/index.zh-CN.md +++ b/components/anchor/doc/index.zh-CN.md @@ -17,15 +17,17 @@ title: Anchor | 成员 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | -| affix | 固定模式 | boolean | false | -| bounds | 锚点区域边界 | number | 5(px) | -| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | -| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | -| showInkInFixed | 固定模式是否显示小圆点 | boolean | false | +| nzAffix | 固定模式 | boolean | true | +| nzBounds | 锚点区域边界 | number | 5(px) | +| nzOffsetBottom | 距离窗口底部达到指定偏移量后触发 | number | | +| nzOffsetTop | 距离窗口顶部达到指定偏移量后触发 | number | | +| nzShowInkInFixed | 固定模式是否显示小圆点 | boolean | false | +| nzClick | 点击项触发 | EventEmitter | - | +| nzScroll | 滚动至某锚点时触发 | EventEmitter | - | ### Link Props | 成员 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | -| href | 锚点链接 | string | | -| title | 文字内容 | string丨ReactNode | | +| nzHref | 锚点链接 | string | | +| nzTitle | 文字内容 | string丨TemplateRef | | diff --git a/components/anchor/index.ts b/components/anchor/index.ts new file mode 100644 index 00000000000..7e1a213e3ea --- /dev/null +++ b/components/anchor/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/components/anchor/nz-anchor-link.component.ts b/components/anchor/nz-anchor-link.component.ts index f4946bbd45e..cb0b9c7615e 100644 --- a/components/anchor/nz-anchor-link.component.ts +++ b/components/anchor/nz-anchor-link.component.ts @@ -3,9 +3,10 @@ import { ContentChild, ElementRef, HostBinding, - HostListener, Input, - TemplateRef + OnDestroy, + OnInit, + TemplateRef, } from '@angular/core'; import { NzAnchorComponent } from './nz-anchor.component'; @@ -14,9 +15,8 @@ import { NzAnchorComponent } from './nz-anchor.component'; selector : 'nz-link', preserveWhitespaces: false, template : ` - - {{ nzTitle }} - + + {{ titleStr }} `, @@ -25,30 +25,40 @@ import { NzAnchorComponent } from './nz-anchor.component'; 'style' : 'display:block' } }) -export class NzAnchorLinkComponent { +export class NzAnchorLinkComponent implements OnInit, OnDestroy { - @Input() nzHref: string; + @Input() nzHref = '#'; - @Input() nzTitle: string; + titleStr = ''; + titleTpl: TemplateRef; + @Input() + set nzTitle(value: string | TemplateRef) { + if (value instanceof TemplateRef) { + this.titleTpl = value; + } else { + this.titleStr = value; + } + } @ContentChild('nzTemplate') nzTemplate: TemplateRef; @HostBinding('class.ant-anchor-link-active') active: boolean = false; - @HostListener('click') - _onClick(): void { - this._anchorComp.scrollTo(this); + constructor(public el: ElementRef, private anchorComp: NzAnchorComponent) { } - constructor(public el: ElementRef, private _anchorComp: NzAnchorComponent) { - this._anchorComp.add(this); + ngOnInit(): void { + this.anchorComp.registerLink(this); } goToClick(e: Event): void { e.preventDefault(); e.stopPropagation(); - this._anchorComp.scrollTo(this); - window.location.hash = this.nzHref; - // return false; + this.anchorComp.handleScrollTo(this); + } + + ngOnDestroy(): void { + this.anchorComp.unregisterLink(this); } + } diff --git a/components/anchor/nz-anchor.component.ts b/components/anchor/nz-anchor.component.ts index 190433eb904..478b9c276ff 100644 --- a/components/anchor/nz-anchor.component.ts +++ b/components/anchor/nz-anchor.component.ts @@ -1,22 +1,23 @@ import { DOCUMENT } from '@angular/common'; import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, EventEmitter, Inject, Input, OnDestroy, - OnInit, Output, - Renderer2, ViewChild } from '@angular/core'; import { Subscription } from 'rxjs/Subscription'; import { fromEvent } from 'rxjs/observable/fromEvent'; -import { distinctUntilChanged } from 'rxjs/operators/distinctUntilChanged'; -import { throttleTime } from 'rxjs/operators/throttleTime'; +import { distinctUntilChanged, throttleTime } from 'rxjs/operators'; import { NzScrollService } from '../core/scroll/nz-scroll.service'; +import { toBoolean, toNumber } from '../core/util/convert'; import { NzAnchorLinkComponent } from './nz-anchor-link.component'; @@ -25,36 +26,81 @@ interface Section { top: number; } +const sharpMatcherRegx = /#([^#]+)$/; + @Component({ selector : 'nz-anchor', preserveWhitespaces: false, template : ` -
-
+ + + + +
+
-
+
- ` +
`, + changeDetection: ChangeDetectionStrategy.OnPush }) -export class NzAnchorComponent implements OnDestroy, OnInit { +export class NzAnchorComponent implements OnDestroy, AfterViewInit { private links: NzAnchorLinkComponent[] = []; - private scroll$: Subscription = null; - private target: Element = null; private animating = false; - private doc: Document; + private target: Element = null; + /** @private */ + scroll$: Subscription = null; + /** @private */ + visible = false; + /** @private */ + wrapperStyle: {} = { 'max-height': '100vh' }; + @ViewChild('wrap') private wrap: ElementRef; + @ViewChild('ink') private ink: ElementRef; + + // region: fields + + private _affix: boolean = true; + @Input() + set nzAffix(value: boolean) { + this._affix = toBoolean(value); + } + get nzAffix(): boolean { + return this._affix; + } - @ViewChild('container') - private container: ElementRef; + private _bounds: number = 5; + @Input() + set nzBounds(value: number) { + this._bounds = toNumber(value, 5); + } + get nzBounds(): number { + return this._bounds; + } - @ViewChild('ball') - private ball: ElementRef; + private _offsetTop: number; + @Input() + set nzOffsetTop(value: number) { + this._offsetTop = toNumber(value, 0); + this.wrapperStyle = { + 'max-height': `calc(100vh - ${this._offsetTop}px)` + }; + } + get nzOffsetTop(): number { + return this._offsetTop; + } - _top = 0; - _visible = false; + private _showInkInFixed: boolean = false; + @Input() + set nzShowInkInFixed(value: boolean) { + this._showInkInFixed = toBoolean(value); + } + get nzShowInkInFixed(): boolean { + return this._showInkInFixed; + } @Input() set nzTarget(el: Element) { @@ -62,38 +108,71 @@ export class NzAnchorComponent implements OnDestroy, OnInit { this.registerScrollEvent(); } - @Input() nzOffsetTop = 0; - - @Input() nzBounds = 5; + @Output() nzClick: EventEmitter = new EventEmitter(); @Output() nzScroll: EventEmitter = new EventEmitter(); + // endregion + /* tslint:disable-next-line:no-any */ - constructor(private scrollSrv: NzScrollService, private _renderer: Renderer2, @Inject(DOCUMENT) doc: any) { - this.doc = doc; + constructor(private scrollSrv: NzScrollService, @Inject(DOCUMENT) private doc: any, private cd: ChangeDetectorRef) { } - ngOnInit(): void { - if (!this.scroll$) { - this.registerScrollEvent(); - } + registerLink(link: NzAnchorLinkComponent): void { + this.links.push(link); + } + + unregisterLink(link: NzAnchorLinkComponent): void { + this.links.splice(this.links.indexOf(link), 1); } private getTarget(): Element | Window { return this.target || window; } - private handleScroll(): void { + ngAfterViewInit(): void { + this.registerScrollEvent(); + } + + ngOnDestroy(): void { + this.removeListen(); + } + + private registerScrollEvent(): void { + this.removeListen(); + this.scroll$ = fromEvent(this.getTarget(), 'scroll').pipe(throttleTime(50), distinctUntilChanged()) + .subscribe(e => this.handleScroll()); + // 由于页面刷新时滚动条位置的记忆 + // 倒置在dom未渲染完成,导致计算不正确 + setTimeout(() => this.handleScroll()); + } + + private removeListen(): void { + if (this.scroll$) this.scroll$.unsubscribe(); + } + + private getOffsetTop(element: HTMLElement): number { + if (!element || !element.getClientRects().length) return 0; + const rect = element.getBoundingClientRect(); + if (!rect.width && !rect.height) return rect.top; + return rect.top - element.ownerDocument.documentElement.clientTop; + } + + handleScroll(): void { if (this.animating) { return; } const sections: Section[] = []; + const scope = (this.nzOffsetTop || 0) + this.nzBounds; this.links.forEach(comp => { - comp.active = false; - const target = this.doc.querySelector(comp.nzHref); - const top = this.scrollSrv.getOffset(target).top; - if (target && top < this.nzOffsetTop + this.nzBounds) { + const sharpLinkMatch = sharpMatcherRegx.exec(comp.nzHref.toString()); + if (!sharpLinkMatch) { + return; + } + const target = this.doc.getElementById(sharpLinkMatch[1]); + if (target && this.getOffsetTop(target) < scope) { + const top = this.getOffsetTop(target); sections.push({ top, comp @@ -101,62 +180,45 @@ export class NzAnchorComponent implements OnDestroy, OnInit { } }); - this._visible = !!sections.length; - if (!this._visible) { - return; + this.visible = !!sections.length; + if (!this.visible) { + this.clearActive(); + this.cd.detectChanges(); + } else { + const maxSection = sections.reduce((prev, curr) => curr.top > prev.top ? curr : prev); + this.handleActive(maxSection.comp); } - - const maxSection = sections.reduce((prev, curr) => curr.top > prev.top ? curr : prev); - maxSection.comp.active = true; - - const linkNode = (maxSection.comp.el.nativeElement as HTMLDivElement).querySelector('.ant-anchor-link-title') as HTMLElement; - this.ball.nativeElement.style.top = `${linkNode.offsetTop + linkNode.clientHeight / 2 - 4.5}px`; - - this.nzScroll.emit(maxSection.comp); } - private removeListen(): void { - if (this.scroll$) { - this.scroll$.unsubscribe(); - } + private clearActive(): void { + this.links.forEach(i => i.active = false); } - private registerScrollEvent(): void { - this.removeListen(); - // 由于页面刷新时滚动条位置的记忆 - // 倒置在dom未渲染完成,导致计算不正确(500ms用于延后执行解决) - setTimeout(() => { - this.handleScroll(); - }, 500); - this.scroll$ = fromEvent(this.getTarget(), 'scroll').pipe(throttleTime(50), distinctUntilChanged()) - .subscribe(e => { - this.handleScroll(); - }); - } + private handleActive(comp: NzAnchorLinkComponent): void { + this.clearActive(); + + comp.active = true; + this.cd.detectChanges(); - add(linkComp: NzAnchorLinkComponent): void { - this.links.push(linkComp); + const linkNode = (comp.el.nativeElement as HTMLDivElement).querySelector('.ant-anchor-link-title') as HTMLElement; + this.ink.nativeElement.style.top = `${linkNode.offsetTop + linkNode.clientHeight / 2 - 4.5}px`; + + this.nzScroll.emit(comp); } - /** 设置滚动条至 `linkComp` 所处位置 */ - scrollTo(linkComp: NzAnchorLinkComponent): void { + handleScrollTo(linkComp: NzAnchorLinkComponent): void { const el = this.doc.querySelector(linkComp.nzHref); - if (!el) { - return; - } + if (!el) return; this.animating = true; const containerScrollTop = this.scrollSrv.getScroll(this.getTarget()); const elOffsetTop = this.scrollSrv.getOffset(el).top; - const targetScrollTop = containerScrollTop + elOffsetTop - this.nzOffsetTop; + const targetScrollTop = containerScrollTop + elOffsetTop - (this.nzOffsetTop || 0); this.scrollSrv.scrollTo(this.getTarget(), targetScrollTop, null, () => { this.animating = false; - this.handleScroll(); + this.handleActive(linkComp); }); - } - - ngOnDestroy(): void { - this.removeListen(); + this.nzClick.emit(linkComp.nzHref); } } diff --git a/components/anchor/nz-anchor.module.ts b/components/anchor/nz-anchor.module.ts index 376b2dcb214..9b9766c44bf 100644 --- a/components/anchor/nz-anchor.module.ts +++ b/components/anchor/nz-anchor.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { NzAffixModule } from '../affix/nz-affix.module'; import { SCROLL_SERVICE_PROVIDER } from '../core/scroll/nz-scroll.service'; import { NzAnchorLinkComponent } from './nz-anchor-link.component'; @@ -9,7 +10,7 @@ import { NzAnchorComponent } from './nz-anchor.component'; @NgModule({ declarations: [ NzAnchorComponent, NzAnchorLinkComponent ], exports : [ NzAnchorComponent, NzAnchorLinkComponent ], - imports : [ CommonModule ], + imports : [ CommonModule, NzAffixModule ], providers : [ SCROLL_SERVICE_PROVIDER ] }) export class NzAnchorModule { diff --git a/components/anchor/public-api.ts b/components/anchor/public-api.ts new file mode 100644 index 00000000000..1ac04178c40 --- /dev/null +++ b/components/anchor/public-api.ts @@ -0,0 +1,3 @@ +export * from './nz-anchor-link.component'; +export * from './nz-anchor.component'; +export * from './nz-anchor.module'; diff --git a/components/back-top/back-top.spec.ts b/components/back-top/back-top.spec.ts new file mode 100644 index 00000000000..1ff07e05714 --- /dev/null +++ b/components/back-top/back-top.spec.ts @@ -0,0 +1,245 @@ +import { + Component, + DebugElement, + ViewChild +} from '@angular/core'; +import { + fakeAsync, + tick, + ComponentFixture, + TestBed +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { NzScrollService } from '../core/scroll/nz-scroll.service'; + +import { NzBackTopComponent } from './nz-back-top.component'; +import { NzBackTopModule } from './nz-back-top.module'; + +describe('Component:nz-back-top', () => { + let scrollService: MockNzScrollService; + let fixture: ComponentFixture; + let context: TestBackTopComponent; + let debugElement: DebugElement; + let component: NzBackTopComponent; + let componentObject: NzBackTopPageObject; + const defaultVisibilityHeight = 400; + + class NzBackTopPageObject { + scrollTo(el: Element | Window, scrollTop: number): void { + scrollService.mockTopOffset = scrollTop; + el.dispatchEvent(new Event('scroll')); + } + + clickBackTop(): void { + this.backTopButton().nativeElement.click(); + } + + backTopButton(): DebugElement { + return debugElement.query(By.css('.ant-back-top')); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NzBackTopModule, + NoopAnimationsModule + ], + declarations: [TestBackTopComponent, TestBackTopTemplateComponent], + providers: [ + { + provide: NzScrollService, + useClass: MockNzScrollService + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestBackTopComponent); + context = fixture.componentInstance; + component = fixture.componentInstance.nzBackTopComponent; + componentObject = new NzBackTopPageObject(); + debugElement = fixture.debugElement; + scrollService = TestBed.get(NzScrollService); + }); + + describe('[default]', () => { + it(`should not show when scroll is below ${defaultVisibilityHeight}`, fakeAsync(() => { + componentObject.scrollTo(window, defaultVisibilityHeight - 1); + tick(); + fixture.detectChanges(); + expect(componentObject.backTopButton() === null).toBe(true); + })); + + it(`should not show when scroll is at ${defaultVisibilityHeight}`, fakeAsync(() => { + componentObject.scrollTo(window, defaultVisibilityHeight); + tick(); + fixture.detectChanges(); + + expect(componentObject.backTopButton() === null).toBe(true); + })); + + describe(`when scrolled at least ${defaultVisibilityHeight + 1}`, () => { + beforeEach(fakeAsync(() => { + componentObject.scrollTo(window, defaultVisibilityHeight + 1); + tick(); + fixture.detectChanges(); + })); + + it(`should show back to top button`, () => { + expect(componentObject.backTopButton() === null).toBe(false); + }); + + it(`should show default template`, () => { + expect(debugElement.query(By.css('.ant-back-top-content')) === null).toBe(false); + }); + + it(`should scroll to top when button is clicked`, fakeAsync(() => { + componentObject.clickBackTop(); + tick(); + + expect(scrollService.getScroll(window)).toEqual(0); + })); + }); + }); + + describe('[nzVisibilityHeight]', () => { + const customVisibilityHeight = 100; + + beforeEach(() => { + component.nzVisibilityHeight = customVisibilityHeight; + }); + + it(`should not show when scroll is below ${customVisibilityHeight}`, fakeAsync(() => { + componentObject.scrollTo(window, customVisibilityHeight - 1); + tick(); + fixture.detectChanges(); + + expect(componentObject.backTopButton() === null).toBe(true); + })); + + it(`should not show when scroll is at ${customVisibilityHeight}`, fakeAsync(() => { + componentObject.scrollTo(window, customVisibilityHeight); + tick(); + fixture.detectChanges(); + + expect(componentObject.backTopButton() === null).toBe(true); + })); + + describe(`when scrolled at least ${customVisibilityHeight + 1}`, () => { + beforeEach(fakeAsync(() => { + componentObject.scrollTo(window, customVisibilityHeight + 1); + tick(); + fixture.detectChanges(); + })); + + it(`should show back to top button`, () => { + expect(componentObject.backTopButton() === null).toBe(false); + }); + }); + }); + + describe('(nzClick)', () => { + beforeEach(fakeAsync(() => { + componentObject.scrollTo(window, defaultVisibilityHeight + 1); + tick(); + fixture.detectChanges(); + })); + + describe('when clicked', () => { + it(`emit event on nzClick`, fakeAsync((done) => { + component.nzClick.subscribe((returnValue) => { + expect(returnValue).toBe(true); + done(); + }); + + componentObject.clickBackTop(); + tick(); + })); + }); + }); + + describe('[nzTarget]', () => { + let fakeTarget: HTMLElement; + beforeEach(fakeAsync(() => { + fakeTarget = debugElement.query(By.css('#fakeTarget')).nativeElement; + component.nzTarget = fakeTarget; + })); + + it('window scroll does not show the button', fakeAsync(() => { + componentObject.scrollTo(window, defaultVisibilityHeight + 1); + tick(); + fixture.detectChanges(); + + expect(componentObject.backTopButton() === null).toBe(true); + })); + + it('element scroll shows the button', fakeAsync(() => { + const throttleTime = 50; + + componentObject.scrollTo(fakeTarget, defaultVisibilityHeight + 1); + tick(throttleTime + 1); + fixture.detectChanges(); + + expect(componentObject.backTopButton() === null).toBe(false); + })); + }); + + describe('#nzTemplate', () => { + it(`should show custom template`, fakeAsync(() => { + let fixtureTemplate: ComponentFixture; + let contextTemplate: TestBackTopTemplateComponent; + fixtureTemplate = TestBed.createComponent(TestBackTopTemplateComponent); + contextTemplate = fixture.componentInstance; + + componentObject.scrollTo(window, defaultVisibilityHeight + 1); + tick(); + fixtureTemplate.detectChanges(); + expect(fixtureTemplate.debugElement.query(By.css('.this-is-my-template')) === null).toBe(false); + })); + }); +}); + +@Component({ + template: ` + +
+` +}) +class TestBackTopComponent { + @ViewChild(NzBackTopComponent) + nzBackTopComponent: NzBackTopComponent; +} + +@Component({ + template: ` + my comp + + +
+
+
+` +}) +class TestBackTopTemplateComponent { + @ViewChild(NzBackTopComponent) + nzBackTopComponent: NzBackTopComponent; +} + +class MockNzScrollService { + mockTopOffset: number; + + getScroll(el?: Element | Window, top: boolean = true): number { + return this.mockTopOffset; + } + + scrollTo( + containerEl: Element | Window, + targetTopValue: number = 0, + easing?: {}, + callback?: {} + ): void { + this.mockTopOffset = targetTopValue; + } +} diff --git a/components/back-top/demo/basic.md b/components/back-top/demo/basic.md new file mode 100644 index 00000000000..765576a7fe5 --- /dev/null +++ b/components/back-top/demo/basic.md @@ -0,0 +1,14 @@ +--- +order: 0 +title: + zh-CN: 基本 + en-US: Basic +--- + +## zh-CN + +最简单的用法。 + +## en-US + +The most basic usage. \ No newline at end of file diff --git a/components/back-top/demo/basic.ts b/components/back-top/demo/basic.ts new file mode 100644 index 00000000000..e0f79aefde8 --- /dev/null +++ b/components/back-top/demo/basic.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'nz-demo-back-top-basic', + template: ` + + Scroll down to see the bottom-right + gray + button. + `, + styles: [` + :host ::ng-deep strong { + color: rgba(64, 64, 64, 0.6); + } + `] +}) +export class NzDemoBackTopBasicComponent { } \ No newline at end of file diff --git a/components/back-top/demo/custom.md b/components/back-top/demo/custom.md new file mode 100644 index 00000000000..75f7ffba335 --- /dev/null +++ b/components/back-top/demo/custom.md @@ -0,0 +1,14 @@ +--- +order: 2 +title: + zh-CN: 自定义样式 + en-US: Custom style +--- + +## zh-CN + +可以自定义回到顶部按钮的样式,限制宽高:`40px * 40px`。 + +## en-US + +You can customize the style of the button, just note the size limit: no more than `40px * 40px`. diff --git a/components/back-top/demo/custom.ts b/components/back-top/demo/custom.ts new file mode 100644 index 00000000000..6c4fd02f26f --- /dev/null +++ b/components/back-top/demo/custom.ts @@ -0,0 +1,38 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'nz-demo-back-top-custom', + template: ` + + +
UP
+
+
+ Scroll down to see the bottom-right + blue + button. + `, + styles: [` + :host ::ng-deep .ant-back-top { + bottom: 100px; + } + :host ::ng-deep .ant-back-top-inner { + height: 40px; + width: 40px; + line-height: 40px; + border-radius: 4px; + background-color: #1088e9; + color: #fff; + text-align: center; + font-size: 20px; + } + :host ::ng-deep strong { + color: #1088e9; + } + `] +}) +export class NzDemoBackTopCustomComponent { + notify() { + console.log('notify'); + } +} diff --git a/components/back-top/demo/target.md b/components/back-top/demo/target.md new file mode 100644 index 00000000000..a1ba04a64d9 --- /dev/null +++ b/components/back-top/demo/target.md @@ -0,0 +1,14 @@ +--- +order: 4 +title: + zh-CN: 滚动容器 + en-US: Using nzTarget +--- + +## zh-CN + +设置 `nzTarget` 参数,允许对某个容器返回顶部。 + +## en-US + +specifies the scrollable area dom node. diff --git a/components/back-top/demo/target.ts b/components/back-top/demo/target.ts new file mode 100644 index 00000000000..862178f238d --- /dev/null +++ b/components/back-top/demo/target.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'nz-demo-back-top-target', + template: ` + Scroll down to see the bottom-right + gray + button. +
+
+ +
+ `, + styles: [` + :host ::ng-deep .long-div { + height: 300px; + overflow-y: scroll; + background-image: url(//zos.alipayobjects.com/rmsportal/RmjwQiJorKyobvI.jpg); + } + + :host ::ng-deep .long-div-inner { + height: 1500px; + } + + :host ::ng-deep .long-div .ant-back-top { + right: 150px; + } + + :host ::ng-deep strong { + color: rgba(64, 64, 64, 0.6); + } + `] +}) +export class NzDemoBackTopTargetComponent { } diff --git a/components/back-top/doc/index.en-US.md b/components/back-top/doc/index.en-US.md new file mode 100644 index 00000000000..d1990e2bb7e --- /dev/null +++ b/components/back-top/doc/index.en-US.md @@ -0,0 +1,24 @@ +--- +category: Components +type: Other +title: BackTop +--- + +`nz-back-top` makes it easy to go back to the top of the page. + +## When To Use + +- When the page content is very long. +- When you need to go back to the top very frequently in order to view the contents. + +## API + +> The distance to the bottom is set to `50px` by default, which is overridable. +> If you decide to use custom styles, please note the size limit: no more than `40px * 40px`. + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| nzTemplate | custom content | ng-template | - | +| nzVisibilityHeight | the `nz-back-top` button will not show until the scroll height reaches this value | number | `400` | +| nzClick | a callback function, which can be executed when you click the button | EventEmitter | - | +| nzTarget | specifies the scrollable area dom node | Element | `window` | \ No newline at end of file diff --git a/components/back-top/doc/index.zh-CN.md b/components/back-top/doc/index.zh-CN.md new file mode 100644 index 00000000000..ead3d564dab --- /dev/null +++ b/components/back-top/doc/index.zh-CN.md @@ -0,0 +1,25 @@ +--- +category: Components +subtitle: 回到顶部 +type: Other +title: BackTop +--- + +返回页面顶部的操作按钮。 + +## 何时使用 + +- 当页面内容区域比较长时; +- 当用户需要频繁返回顶部查看相关内容时。 + +## API + +> 有默认样式,距离底部 `50px`,可覆盖。 +> 自定义样式宽高不大于 `40px * 40px`。 + +| 成员 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| nzTemplate | 自定义内容,见示例 | ng-template | - | +| nzVisibilityHeight | 滚动高度达到此参数值才出现 `nz-back-top` | number | `400` | +| nzClick | 点击按钮的回调函数 | EventEmitter | - | +| nzTarget | 设置需要监听其滚动事件的元素,值为一个返回对应 DOM 元素的函数 | Element | `window` | diff --git a/components/back-top/index.ts b/components/back-top/index.ts new file mode 100644 index 00000000000..7e1a213e3ea --- /dev/null +++ b/components/back-top/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/components/back-top/nz-back-top.component.ts b/components/back-top/nz-back-top.component.ts new file mode 100644 index 00000000000..3341bcb75ac --- /dev/null +++ b/components/back-top/nz-back-top.component.ts @@ -0,0 +1,117 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChild, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + TemplateRef, +} from '@angular/core'; + +import { + animate, + style, + transition, + trigger, +} from '@angular/animations'; + +import { Subscription } from 'rxjs/Subscription'; +import { fromEvent } from 'rxjs/observable/fromEvent'; +import { distinctUntilChanged } from 'rxjs/operators/distinctUntilChanged'; +import { throttleTime } from 'rxjs/operators/throttleTime'; + +import { NzScrollService } from '../core/scroll/nz-scroll.service'; +import { toNumber } from '../core/util/convert'; + +@Component({ + selector : 'nz-back-top', + animations : [ + trigger('enterLeave', [ + transition(':enter', [ + style({ opacity: 0 }), + animate(300, style({ opacity: 1 })) + ]), + transition(':leave', [ + style({ opacity: 1 }), + animate(300, style({ opacity: 0 })) + ]) + ]) + ], + template : ` +
+ +
+
+ +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + preserveWhitespaces: false +}) +export class NzBackTopComponent implements OnInit, OnDestroy { + + private scroll$: Subscription = null; + private target: HTMLElement = null; + + visible: boolean = false; + + @Input() nzTemplate: TemplateRef; + + private _visibilityHeight: number = 400; + @Input() + set nzVisibilityHeight(value: number) { + this._visibilityHeight = toNumber(value, 400); + } + get nzVisibilityHeight(): number { + return this._visibilityHeight; + } + + @Input() + set nzTarget(el: HTMLElement) { + this.target = el; + this.registerScrollEvent(); + } + + @Output() nzClick: EventEmitter = new EventEmitter(); + + constructor(private scrollSrv: NzScrollService, private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + if (!this.scroll$) this.registerScrollEvent(); + } + + clickBackTop(): void { + this.scrollSrv.scrollTo(this.getTarget(), 0); + this.nzClick.emit(true); + } + + private getTarget(): HTMLElement | Window { + return this.target || window; + } + + private handleScroll(): void { + if (this.visible === this.scrollSrv.getScroll(this.getTarget()) > this.nzVisibilityHeight) return; + this.visible = !this.visible; + this.cd.detectChanges(); + } + + private removeListen(): void { + if (this.scroll$) this.scroll$.unsubscribe(); + } + + private registerScrollEvent(): void { + this.removeListen(); + this.handleScroll(); + this.scroll$ = fromEvent(this.getTarget(), 'scroll').pipe(throttleTime(50), distinctUntilChanged()) + .subscribe(e => this.handleScroll()); + } + + ngOnDestroy(): void { + this.removeListen(); + } + +} diff --git a/components/back-top/nz-back-top.module.ts b/components/back-top/nz-back-top.module.ts new file mode 100644 index 00000000000..7e22b3a825a --- /dev/null +++ b/components/back-top/nz-back-top.module.ts @@ -0,0 +1,15 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SCROLL_SERVICE_PROVIDER } from '../core/scroll/nz-scroll.service'; + +import { NzBackTopComponent } from './nz-back-top.component'; + +@NgModule({ + declarations: [ NzBackTopComponent ], + exports : [ NzBackTopComponent ], + imports : [ CommonModule ], + providers : [ SCROLL_SERVICE_PROVIDER ] +}) +export class NzBackTopModule { +} diff --git a/components/back-top/public-api.ts b/components/back-top/public-api.ts new file mode 100644 index 00000000000..6a0ebbd91ee --- /dev/null +++ b/components/back-top/public-api.ts @@ -0,0 +1,2 @@ +export * from './nz-back-top.component'; +export * from './nz-back-top.module'; diff --git a/components/back-top/style/index.less b/components/back-top/style/index.less new file mode 100644 index 00000000000..eba87157d02 --- /dev/null +++ b/components/back-top/style/index.less @@ -0,0 +1,40 @@ +@import "../../style/themes/default"; +@import "../../style/mixins/index"; + +@backtop-prefix-cls: ~"@{ant-prefix}-back-top"; + +.@{backtop-prefix-cls} { + .reset-component; + z-index: @zindex-back-top; + position: fixed; + right: 100px; + bottom: 50px; + height: 40px; + width: 40px; + cursor: pointer; + + &-content { + height: 40px; + width: 40px; + border-radius: 20px; + background-color: @back-top-bg; + color: @back-top-color; + text-align: center; + transition: all .3s @ease-in-out; + overflow: hidden; + + &:hover { + background-color: @back-top-hover-bg; + transition: all .3s @ease-in-out; + } + } + + &-icon { + margin: 12px auto; + width: 14px; + height: 16px; + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAoCAYAAACWwljjAAAABGdBTUEAALGPC/xhBQAAAbtJREFUWAntmMtKw0AUhhMvS5cuxILgQlRUpIggIoKIIoigG1eC+AA+jo+i6FIXBfeuXIgoeKVeitVWJX5HWhhDksnUpp3FDPyZk3Nm5nycmZKkXhAEOXSA3lG7muTeRzmfy6HneUvIhnYkQK+Q9NhAA0Opg0vBEhjBKHiyb8iGMyQMOYuK41BcBSypAL+MYXSKjtFAW7EAGEO3qN4uMQbbAkXiSfRQJ1H6a+yhlkKRcAoVFYiweYNjtCVQJJpBz2GCiPt7fBOZQpFgDpUikse5HgnkM4Fi4QX0Fpc5wf9EbLqpUCy4jMoJSXWhFwbMNgWKhVbRhy5jirhs9fy/oFhgHVVTJEs7RLZ8sSEoJm6iz7SZDMbJ+/OKERQTttCXQRLToRUmrKWCYuA2+jbN0MB4OQobYShfdTCgn/sL1K36M7TLrN3n+758aPy2rrpR6+/od5E8tf/A1uLS9aId5T7J3CNYihkQ4D9PiMdMC7mp4rjB9kjFjZp8BlnVHJBuO1yFXIV0FdDF3RlyFdJVQBdv5AxVdIsq8apiZ2PyYO1EVykesGfZEESsCkweyR8MUW+V8uJ1gkYipmpdP1pm2aJVPEGzAAAAAElFTkSuQmCC) ~"100%/100%" no-repeat; + } +} + +@import './responsive'; \ No newline at end of file diff --git a/components/back-top/style/responsive.less b/components/back-top/style/responsive.less new file mode 100644 index 00000000000..d466c8326f6 --- /dev/null +++ b/components/back-top/style/responsive.less @@ -0,0 +1,11 @@ +@media screen and (max-width: @screen-md) { + .@{backtop-prefix-cls} { + right: 60px; + } +} + +@media screen and (max-width: @screen-xs) { + .@{backtop-prefix-cls} { + right: 20px; + } +} \ No newline at end of file diff --git a/components/core/polyfill/request-animation.ts b/components/core/polyfill/request-animation.ts index 17ee79c12c1..a28bfe3444a 100644 --- a/components/core/polyfill/request-animation.ts +++ b/components/core/polyfill/request-animation.ts @@ -1,4 +1,4 @@ - +// tslint:disable:no-any typedef no-invalid-this const availablePrefixs = ['moz', 'ms', 'webkit']; function requestAnimationFramePolyfill(): typeof requestAnimationFrame { @@ -28,4 +28,22 @@ function getRequestAnimationFrame(): typeof requestAnimationFrame { : requestAnimationFramePolyfill(); } +export function cancelRequestAnimationFrame(id: number): any { + if (typeof window === 'undefined') { + return null; + } + if (window.cancelAnimationFrame) { + return window.cancelAnimationFrame(id); + } + const prefix = availablePrefixs.filter(key => + `${key}CancelAnimationFrame` in window || `${key}CancelRequestAnimationFrame` in window, + )[0]; + + return prefix ? + ( + (window as any)[`${prefix}CancelAnimationFrame`] || + (window as any)[`${prefix}CancelRequestAnimationFrame`] + ).call(this, id) : clearTimeout(id); +} + export const reqAnimFrame = getRequestAnimationFrame(); diff --git a/components/core/scroll/nz-scroll.service.spec.ts b/components/core/scroll/nz-scroll.service.spec.ts new file mode 100644 index 00000000000..28ccb3b06b3 --- /dev/null +++ b/components/core/scroll/nz-scroll.service.spec.ts @@ -0,0 +1,60 @@ +/* tslint:disable:no-unused-variable no-inferrable-types no-any prefer-const */ +import { DOCUMENT, PlatformLocation } from '@angular/common'; +import { ReflectiveInjector } from '@angular/core'; + +import { NzScrollService } from './nz-scroll.service'; + +describe('NzScrollService', () => { + + const TOP: number = 10; + let injector: ReflectiveInjector; + let document: MockDocument; + let location: MockPlatformLocation; + let scrollService: NzScrollService; + + class MockDocument { + body = new MockElement(); + documentElement = new MockDocumentElement(); + } + + class MockDocumentElement { + scrollTop = jasmine.createSpy('scrollTop'); + } + + class MockElement { + scrollTop = jasmine.createSpy('scrollTop'); + } + + class MockPlatformLocation { + hash: string; + } + + beforeEach(() => { + spyOn(window, 'scrollBy'); + }); + + beforeEach(() => { + injector = ReflectiveInjector.resolveAndCreate([ + NzScrollService, + { provide: DOCUMENT, useClass: MockDocument }, + { provide: PlatformLocation, useClass: MockPlatformLocation } + ]); + location = injector.get(PlatformLocation); + document = injector.get(DOCUMENT); + scrollService = injector.get(NzScrollService); + }); + + describe('#setScrollTop', () => { + it(`should scroll to window ${TOP} x`, () => { + scrollService.setScrollTop(window, TOP); + expect(document.body.scrollTop).toBe(TOP); + }); + + it(`should scroll to dom element ${TOP} x`, () => { + let el: Element = new MockElement() as any; + scrollService.setScrollTop(el, TOP); + expect(el.scrollTop).toBe(TOP); + }); + }); + +}); diff --git a/components/core/scroll/nz-scroll.service.ts b/components/core/scroll/nz-scroll.service.ts index f123d1dcaf3..f0242657b00 100644 --- a/components/core/scroll/nz-scroll.service.ts +++ b/components/core/scroll/nz-scroll.service.ts @@ -1,5 +1,6 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable, Optional, Provider, SkipSelf } from '@angular/core'; + import { reqAnimFrame } from '../polyfill/request-animation'; export type EasyingFn = (t: number, b: number, c: number, d: number) => number; diff --git a/components/core/util/check.ts b/components/core/util/check.ts index 4b38996e1f1..7cfc5063bb3 100644 --- a/components/core/util/check.ts +++ b/components/core/util/check.ts @@ -3,6 +3,29 @@ export function isNotNil(value: any): boolean { return (typeof(value) !== 'undefined') && value !== null; } +/** 校验对象是否相等 */ +export function shallowEqual(objA: {}, objB: {}): boolean { + if (objA === objB) return true; + + if (typeof objA !== 'object' || !objA || typeof objB !== 'object' || !objB) return false; + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) return false; + + const bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB); + + // tslint:disable-next-line:prefer-for-of + for (let idx = 0; idx < keysA.length; idx++) { + const key = keysA[idx]; + if (!bHasOwnProperty(key)) return false; + if (objA[key] !== objB[key]) return false; + } + + return true; +} + export function isInteger(value: string | number): boolean { return typeof value === 'number' && isFinite(value) && diff --git a/components/core/util/throttleByAnimationFrame.ts b/components/core/util/throttleByAnimationFrame.ts new file mode 100644 index 00000000000..20d8c8d8b5c --- /dev/null +++ b/components/core/util/throttleByAnimationFrame.ts @@ -0,0 +1,47 @@ +// tslint:disable:no-any typedef no-invalid-this +import { cancelRequestAnimationFrame, reqAnimFrame } from '../polyfill/request-animation'; + +export default function throttleByAnimationFrame(fn: () => void) { + let requestId: number | null; + + const later = (args: any[]) => () => { + requestId = null; + fn(...args); + }; + + const throttled = (...args: any[]) => { + if (requestId == null) { + requestId = reqAnimFrame(later(args)); + } + }; + + // tslint:disable-next-line:no-non-null-assertion + (throttled as any).cancel = () => cancelRequestAnimationFrame(requestId!); + + return throttled; +} + +export function throttleByAnimationFrameDecorator() { + return function(target: any, key: string, descriptor: any) { + const fn = descriptor.value; + let definingProperty = false; + return { + configurable: true, + get() { + if (definingProperty || this === target.prototype || this.hasOwnProperty(key)) { + return fn; + } + + const boundFn = throttleByAnimationFrame(fn.bind(this)); + definingProperty = true; + Object.defineProperty(this, key, { + value: boundFn, + configurable: true, + writable: true, + }); + definingProperty = false; + return boundFn; + }, + }; + }; +} diff --git a/components/index.less b/components/index.less index 6f087b32046..87a03bd5cc8 100644 --- a/components/index.less +++ b/components/index.less @@ -6,6 +6,7 @@ @import "./switch/style/index"; @import "./anchor/style/index"; @import "./affix/style/index"; +@import "./back-top/style/index"; @import "./dropdown/style/index"; @import "./breadcrumb/style/index"; @import "./layout/style/index"; diff --git a/components/index.ts b/components/index.ts index cca37130b8f..b4ccb8cf28c 100644 --- a/components/index.ts +++ b/components/index.ts @@ -3,9 +3,10 @@ import { ModuleWithProviders, NgModule } from '@angular/core'; import { NzAffixModule } from './affix/nz-affix.module'; import { NzAlertModule } from './alert'; import { NzAnchorModule } from './anchor/nz-anchor.module'; -import { NzAvatarModule } from './avatar'; -import { NzBadgeModule } from './badge'; -import { NzBreadCrumbModule } from './breadcrumb'; +import { NzAvatarModule } from './avatar/nz-avatar.module'; +import { NzBackTopModule } from './back-top/nz-back-top.module'; +import { NzBadgeModule } from './badge/nz-badge.module'; +import { NzBreadCrumbModule } from './breadcrumb/nz-breadcrumb.module'; import { NzButtonModule } from './button'; import { NzCalendarModule } from './calendar'; import { NzCardModule } from './card'; @@ -45,6 +46,9 @@ import { NzToolTipModule } from './tooltip/nz-tooltip.module'; import { NzTransferModule } from './transfer'; import { NzUploadModule } from './upload'; +export * from './affix'; +export * from './anchor'; +export * from './back-top'; export * from './button'; export * from './divider'; export * from './grid'; @@ -87,6 +91,7 @@ export * from './modal/public-api'; NzSwitchModule, NzSelectModule, NzMenuModule, + NzBackTopModule, NzAnchorModule, NzAffixModule, NzDropDownModule, diff --git a/scripts/_site/src/style/toc.less b/scripts/_site/src/style/toc.less index 8f9ef077762..f2b2567d5bf 100755 --- a/scripts/_site/src/style/toc.less +++ b/scripts/_site/src/style/toc.less @@ -51,6 +51,14 @@ ul.toc > li { .ant-affix { background: #fff; } + .ant-anchor-link-title { + display: block; + transition: all 0.3s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 110px; + } } .toc-affix-bottom { position: absolute; diff --git a/scripts/template/demo-component.template.ts b/scripts/template/demo-component.template.ts index 76ef2c01f67..7ec95a02dc8 100644 --- a/scripts/template/demo-component.template.ts +++ b/scripts/template/demo-component.template.ts @@ -10,6 +10,10 @@ export class {{componentName}} { expanded = false; @ViewChildren(NzCodeBoxComponent) codeBoxes: QueryList; + goLink(link: string) { + window.location.hash = link; + } + expandAllCode(): void { this.expanded = !this.expanded; this.codeBoxes.forEach(code => { diff --git a/scripts/template/doc-component.template.ts b/scripts/template/doc-component.template.ts index dd06afdecaf..9a1cac0be2e 100644 --- a/scripts/template/doc-component.template.ts +++ b/scripts/template/doc-component.template.ts @@ -6,4 +6,7 @@ import { Component } from '@angular/core'; preserveWhitespaces: false }) export class NzDoc{{componentName}}Component { + goLink(link: string) { + window.location.hash = link; + } } diff --git a/scripts/utils/generate-demo.js b/scripts/utils/generate-demo.js index b16686549d7..8c5ec4e5b94 100644 --- a/scripts/utils/generate-demo.js +++ b/scripts/utils/generate-demo.js @@ -133,10 +133,12 @@ function generateToc(language, name, demoMap) { ); } linkArray.sort((pre, next) => pre.order - next.order); - const link = linkArray.map(link => link.content).join(''); - return `
- ${link} -
`; + const links = linkArray.map(link => link.content).join(''); + return ` + + ${links} + + `; } function generateExample(result) { diff --git a/scripts/utils/generate-docs.js b/scripts/utils/generate-docs.js index 4124b1f6e25..65258bb6fe9 100644 --- a/scripts/utils/generate-docs.js +++ b/scripts/utils/generate-docs.js @@ -41,11 +41,11 @@ function generateToc(meta, raw) { links += `` } } - return `
- + return ` + ${links} -
` + `; } function baseInfo(file, path) {