diff --git a/demo/src/app/components/carousel/demos/carousel-demo.component.html b/demo/src/app/components/carousel/demos/carousel-demo.component.html index ea71e9ce52..4baedd97be 100644 --- a/demo/src/app/components/carousel/demos/carousel-demo.component.html +++ b/demo/src/app/components/carousel/demos/carousel-demo.component.html @@ -1,6 +1,6 @@
- + @@ -19,10 +19,14 @@

Slide {{index}}

+ + git
diff --git a/demo/src/app/components/carousel/demos/carousel-demo.component.ts b/demo/src/app/components/carousel/demos/carousel-demo.component.ts index 648209b7d3..ad933d0444 100644 --- a/demo/src/app/components/carousel/demos/carousel-demo.component.ts +++ b/demo/src/app/components/carousel/demos/carousel-demo.component.ts @@ -8,7 +8,7 @@ export class CarouselDemoComponent { public myInterval:number = 5000; public noWrapSlides:boolean = false; public slides:any[] = []; - public currentSlideIndex: number; + public activeSlideIndex: number; public constructor() { for (let i = 0; i < 4; i++) { @@ -25,12 +25,12 @@ export class CarouselDemoComponent { }); } - public activeSlideChanged(index: number): void { - this.currentSlideIndex = index; + public selectSlide(index: number): void { + this.activeSlideIndex = index; } public removeSlide(index?: number):void { - const toRemove = index ? index : this.currentSlideIndex; + const toRemove = index ? index : this.activeSlideIndex; this.slides.splice(toRemove, 1); } } diff --git a/src/carousel/carousel.component.ts b/src/carousel/carousel.component.ts index f9a92b9a1a..caadb11a3d 100644 --- a/src/carousel/carousel.component.ts +++ b/src/carousel/carousel.component.ts @@ -34,11 +34,11 @@ export enum Direction {UNKNOWN, NEXT, PREV}
  • - + Previous - + Next @@ -47,34 +47,50 @@ export enum Direction {UNKNOWN, NEXT, PREV} }) export class CarouselComponent implements OnDestroy { /** if `true` carousel will not cycle continuously and will have hard stops (prevent looping) */ - @Input() public noWrap:boolean; + @Input() public noWrap: boolean; + /** if `true` will disable pausing on carousel mouse hover */ - @Input() public noPause:boolean; + @Input() public noPause: boolean; + + protected _currentActiveSlide: number; + + /** Will be emitted when active slide has been changed. Part of two-way-bindable [(activeSlide)] property */ + @Output() public activeSlideChange: EventEmitter = new EventEmitter(false); + + /** Index of currently displayed slide(started for 0) */ + @Input() + public set activeSlide(index: number) { + if (this._slides.length && index !== this._currentActiveSlide) { + this._select(index); + } + } + public get activeSlide(): number { + return this._currentActiveSlide; + } + + protected _interval: number; /** * Amount of time in milliseconds to delay between automatically * cycling an item. If false, carousel will not automatically cycle */ @Input() - public get interval():number { + public get interval(): number { return this._interval; } - public set interval(value:number) { + public set interval(value: number) { this._interval = value; this.restartTimer(); } - @Output() public activeSlideChanged: EventEmitter = new EventEmitter(false); - protected _slides: LinkedList = new LinkedList(); public get slides(): SlideComponent[] { return this._slides.toArray(); } - protected currentInterval:any; - protected isPlaying:boolean; - protected destroyed:boolean = false; - protected _interval:number; + protected currentInterval: any; + protected isPlaying: boolean; + protected destroyed: boolean = false; public get isBs4():boolean { return !isBs3(); @@ -84,123 +100,176 @@ export class CarouselComponent implements OnDestroy { Object.assign(this, config); } - public ngOnDestroy():void { + public ngOnDestroy(): void { this.destroyed = true; } + /** + * Adds new slide. If this slide is first in collection - set it as active and starts auto changing + * @param slide + */ public addSlide(slide: SlideComponent): void { this._slides.add(slide); if (this._slides.length === 1) { - slide.active = true; + this._currentActiveSlide = void 0; + this.activeSlide = 0; this.play(); } } + /** + * Removes specified slide. If this slide is active - will roll to another slide + * @param slide + */ public removeSlide(slide: SlideComponent): void { const remIndex = this._slides.indexOf(slide); - if (this.getCurrentSlideIndex() === remIndex) { + if (this._currentActiveSlide === remIndex) { - // behavior in case removing of a current active slide + // removing of active slide + let nextSlideIndex: number = void 0; if (this._slides.length > 1) { - if (this.isLast(remIndex) && this.noWrap) { - - // last slide and looping is disabled - step backward - this._select(Direction.PREV, undefined, true); - } else { - this._select(Direction.NEXT, undefined, true); - } + // if this slide last - will roll to first slide, if noWrap flag is FALSE or to previous, if noWrap is TRUE + // in case, if this slide in middle of collection, index of next slide is same to removed + nextSlideIndex = !this.isLast(remIndex) ? remIndex : + this.noWrap ? remIndex - 1 : 0; } - } + this._slides.remove(remIndex); + + // prevents exception with changing some value after checking + setTimeout(() => { + this._select(nextSlideIndex); + }, 0); + } else { + this._slides.remove(remIndex); + const currentSlideIndex = this.getCurrentSlideIndex(); + setTimeout(() => { + // after removing, need to actualize index of current active slide + this._currentActiveSlide = currentSlideIndex; + this.activeSlideChange.emit(this._currentActiveSlide); + }, 0); - this._slides.remove(remIndex); - this.activeSlideChanged.emit(this.getCurrentSlideIndex()); + } } - public nextSlide(): void { - this._select(Direction.NEXT); + /** + * Rolling to next slide + * @param force: {boolean} if true - will ignore noWrap flag + */ + public nextSlide(force: boolean = false): void { + this.activeSlide = this.findNextSlideIndex(Direction.NEXT, force); } - public previousSlide(): void { - this._select(Direction.PREV); + /** + * Rolling to previous slide + * @param force: {boolean} if true - will ignore noWrap flag + */ + public previousSlide(force: boolean = false): void { + this.activeSlide = this.findNextSlideIndex(Direction.PREV, force); } + /** + * Rolling to specified slide + * @param index: {number} index of slide, which must be shown + */ public selectSlide(index: number): void { - this._select(undefined, index, true); + this.activeSlide = index; } - public play():void { + /** + * Starts a auto changing of slides + */ + public play(): void { if (!this.isPlaying) { this.isPlaying = true; this.restartTimer(); } } - public pause():void { + /** + * Stops a auto changing of slides + */ + public pause(): void { if (!this.noPause) { this.isPlaying = false; this.resetTimer(); } } + /** + * Finds and returns index of currently displayed slide + * @returns {number} + */ public getCurrentSlideIndex(): number { return this._slides.findIndex((slide: SlideComponent) => slide.active); } + /** + * Defines, whether the specified index is last in collection + * @param index + * @returns {boolean} + */ public isLast(index: number): boolean { return index + 1 >= this._slides.length; } /** - * Select slide - * @param direction: {Direction} - * @param nextIndex: {number}(optional) - index of next active slide - * @param force: boolean {optional} - if true, selection will ignore this.noWrap flag(for jumping after removing a current slide) - * @private + * Defines next slide index, depending of direction + * @param direction: Direction(UNKNOWN|PREV|NEXT) + * @param force: {boolean} if TRUE - will ignore noWrap flag, else will return undefined if next slide require wrapping + * @returns {any} */ - private _select(direction: Direction = 0, nextIndex?: number, force: boolean = false): void { - const currentSlideIndex = this.getCurrentSlideIndex(); + private findNextSlideIndex(direction: Direction, force: boolean): number { + let nextSlideIndex: number = 0; - // if this is last slide, need to going forward but looping is disabled - if (!force && (this.isLast(currentSlideIndex) && direction && direction !== Direction.PREV && this.noWrap)) { - this.pause(); - return; + if (!force && (this.isLast(this.activeSlide) && direction !== Direction.PREV && this.noWrap)) { + return void 0; } - let currentSlide = this._slides.get(currentSlideIndex); - let nextSlideIndex: number = !isNaN(nextIndex) ? nextIndex : undefined; - - if (direction !== undefined && direction !== Direction.UNKNOWN) { - switch (direction) { - case Direction.NEXT: - - // if this is last slide, not force, looping is disabled and need to going forward - select current slide, as a next - nextSlideIndex = (!this.isLast(currentSlideIndex)) ? currentSlideIndex + 1 : - (!force && this.noWrap ) ? currentSlideIndex : 0; - break; - case Direction.PREV: - - // if this is first slide, not force, looping is disabled and need to going backward - select current slide, as a next - nextSlideIndex = (currentSlideIndex > 0) ? currentSlideIndex - 1 : - (!force && this.noWrap ) ? currentSlideIndex : this._slides.length - 1; - break; - default: - throw new Error('Wrong direction'); - } + switch (direction) { + case Direction.NEXT: + // if this is last slide, not force, looping is disabled and need to going forward - select current slide, as a next + nextSlideIndex = (!this.isLast(this._currentActiveSlide)) ? this._currentActiveSlide + 1 : + (!force && this.noWrap ) ? this._currentActiveSlide : 0; + break; + case Direction.PREV: + // if this is first slide, not force, looping is disabled and need to going backward - select current slide, as a next + nextSlideIndex = (this._currentActiveSlide > 0) ? this._currentActiveSlide - 1 : + (!force && this.noWrap ) ? this._currentActiveSlide : this._slides.length - 1; + break; + default: + throw new Error('Unknown direction'); } + return nextSlideIndex; + } - if (nextSlideIndex === currentSlideIndex) { + /** + * Sets a slide, which specified through index, as active + * @param index + * @private + */ + private _select(index: number): void { + if (isNaN(index)) { + this.pause(); return; } - - let nextSlide = this._slides.get(nextSlideIndex); - currentSlide.active = false; - nextSlide.active = true; - this.activeSlideChanged.emit(nextSlideIndex); - + let currentSlide = this._slides.get(this._currentActiveSlide); + if (currentSlide) { + currentSlide.active = false; + } + let nextSlide = this._slides.get(index); + if (nextSlide) { + this._currentActiveSlide = index; + nextSlide.active = true; + this.activeSlide = index; + this.activeSlideChange.emit(index); + } } - private restartTimer():any { + /** + * Starts loop of auto changing of slides + */ + private restartTimer(): any { this.resetTimer(); let interval = +this.interval; if (!isNaN(interval) && interval > 0) { @@ -208,7 +277,7 @@ export class CarouselComponent implements OnDestroy { () => { let nInterval = +this.interval; if (this.isPlaying && !isNaN(this.interval) && nInterval > 0 && this.slides.length) { - this._select(Direction.NEXT); + this.nextSlide(); } else { this.pause(); } @@ -217,7 +286,10 @@ export class CarouselComponent implements OnDestroy { } } - private resetTimer():void { + /** + * Stops loop of auto changing of slides + */ + private resetTimer(): void { if (this.currentInterval) { clearInterval(this.currentInterval); this.currentInterval = void 0; diff --git a/src/carousel/carousel.config.ts b/src/carousel/carousel.config.ts index 32e2702d10..e6a8dafac4 100644 --- a/src/carousel/carousel.config.ts +++ b/src/carousel/carousel.config.ts @@ -2,7 +2,12 @@ import { Injectable } from '@angular/core'; @Injectable() export class CarouselConfig { + /** Default interval of auto changing of slides */ public interval: number = 5000; + + /** Is loop of auto changing of slides can be paused */ public noPause: boolean = false; + + /** Is slides can wrap from the last to the first slide */ public noWrap: boolean = false; } diff --git a/src/spec/ng-bootstrap/carousel.spec.ts b/src/spec/ng-bootstrap/carousel.spec.ts new file mode 100644 index 0000000000..71e4825f04 --- /dev/null +++ b/src/spec/ng-bootstrap/carousel.spec.ts @@ -0,0 +1,418 @@ +/* tslint:disable:max-classes-per-file max-file-line-count component-class-suffix */ +/** + * @copyright Angular ng-bootstrap team + */ +import { fakeAsync, discardPeriodicTasks, tick, TestBed, ComponentFixture, inject } from '@angular/core/testing'; +import { createGenericTestComponent } from './test/common'; + +import { By } from '@angular/platform-browser'; +import { Component } from '@angular/core'; + +import { CarouselModule, CarouselComponent, CarouselConfig } from '../../carousel'; + +const createTestComponent = (html: string) => + createGenericTestComponent(html, TestComponent) as ComponentFixture; + +function expectActiveSlides(nativeEl: HTMLDivElement, active: boolean[]): void { + const slideElms = nativeEl.querySelectorAll('.carousel-item'); + const indicatorElms = nativeEl.querySelectorAll('ol.carousel-indicators > li'); + + expect(slideElms.length).toBe(active.length); + expect(indicatorElms.length).toBe(active.length); + + for (let i = 0; i < active.length; i++) { + if (active[i]) { + expect(slideElms[i]).toHaveCssClass('active'); + expect(indicatorElms[i]).toHaveCssClass('active'); + } else { + expect(slideElms[i]).not.toHaveCssClass('active'); + expect(indicatorElms[i]).not.toHaveCssClass('active'); + } + } +} + +describe('ngb-carousel', () => { + beforeEach(() => { + TestBed.configureTestingModule({declarations: [TestComponent], imports: [CarouselModule.forRoot()]}); + }); + + it('should initialize inputs with default values', () => { + const defaultConfig = new CarouselConfig(); + const carousel = new CarouselComponent(new CarouselConfig()); + + expect(carousel.interval).toBe(defaultConfig.interval); + expect(carousel.noWrap).toBe(defaultConfig.noWrap); + // expect(carousel.keyboard).toBe(defaultConfig.keyboard); + }); + + it('should render slides and navigation indicators', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + const fixture = createTestComponent(html); + + const slideElms = fixture.nativeElement.querySelectorAll('.carousel-item'); + expect(slideElms.length).toBe(2); + expect(slideElms[0].textContent).toMatch(/slide1/); + expect(slideElms[1].textContent).toMatch(/slide2/); + + expect(fixture.nativeElement.querySelectorAll('ol.carousel-indicators > li').length).toBe(2); + + discardPeriodicTasks(); + })); + + it('should mark the first slide as active by default', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + expectActiveSlides(fixture.nativeElement, [true, false]); + + discardPeriodicTasks(); + })); + + it('should mark the requested slide as active', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + fixture.componentInstance.activeSlideIndex = 1; + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + it('should auto-correct when slide index is undefined', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + expectActiveSlides(fixture.nativeElement, [true, false]); + + discardPeriodicTasks(); + })); + + it('should change slide on indicator click', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + const indicatorElms = fixture.nativeElement.querySelectorAll('ol.carousel-indicators > li'); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + indicatorElms[1].click(); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + it('should change slide on carousel control click', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + const controlElms = fixture.nativeElement.querySelectorAll('.carousel-control'); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + controlElms[1].click(); // next + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + controlElms[0].click(); // prev + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + discardPeriodicTasks(); + })); + + it('should change slide on time passage (default interval value)', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(6000); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + it('should change slide on time passage (custom interval value)', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(1000); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(1200); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + it('should not change slide on time passage (custom interval value is zero)', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(1000); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(1200); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + discardPeriodicTasks(); + })); + + it('should pause / resume slide change with time passage on mouse enter / leave', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + const carouselDebugEl = fixture.debugElement.query(By.directive(CarouselComponent)); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + carouselDebugEl.children[0].triggerEventHandler('mouseenter', {}); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(6000); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + carouselDebugEl.children[0].triggerEventHandler('mouseleave', {}); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + tick(6000); + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + discardPeriodicTasks(); + })); + + it('should wrap slide changes by default', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + const controlElms = fixture.nativeElement.querySelectorAll('.carousel-control'); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + controlElms[1].click(); // next + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + controlElms[1].click(); // next + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + controlElms[0].click(); // prev + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + it('should not wrap slide changes by when requested', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + + const controlElms = fixture.nativeElement.querySelectorAll('.carousel-control'); + + expectActiveSlides(fixture.nativeElement, [true, false]); + + controlElms[0].click(); // prev + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + controlElms[1].click(); // next + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + controlElms[1].click(); // next + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + })); + + xit('should change on key arrowRight and arrowLeft', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + expectActiveSlides(fixture.nativeElement, [true, false]); + + fixture.debugElement.query(By.directive(CarouselComponent)).triggerEventHandler('keydown.arrowRight', {}); // next() + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + fixture.debugElement.query(By.directive(CarouselComponent)).triggerEventHandler('keydown.arrowLeft', {}); // prev() + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + fixture.componentInstance.keyboard = false; + fixture.detectChanges(); + fixture.debugElement.query(By.directive(CarouselComponent)).triggerEventHandler('keydown.arrowRight', {}); // prev() + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + discardPeriodicTasks(); + + })); + + xit('should listen to keyevents based on keyboard attribute', fakeAsync(() => { + const html = ` + + slide1 + slide2 + + `; + + const fixture = createTestComponent(html); + expectActiveSlides(fixture.nativeElement, [true, false]); + + fixture.componentInstance.keyboard = false; + fixture.detectChanges(); + fixture.debugElement.query(By.directive(CarouselComponent)).triggerEventHandler('keydown.arrowRight', {}); // prev() + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [true, false]); + + fixture.componentInstance.keyboard = true; + fixture.detectChanges(); + fixture.debugElement.query(By.directive(CarouselComponent)).triggerEventHandler('keydown.arrowRight', {}); // next() + fixture.detectChanges(); + expectActiveSlides(fixture.nativeElement, [false, true]); + + discardPeriodicTasks(); + + })); + + describe('Custom config', () => { + let config: CarouselConfig; + + beforeEach(() => { TestBed.configureTestingModule({imports: [CarouselModule.forRoot()]}); }); + + beforeEach(inject([CarouselConfig], (c: CarouselConfig) => { + config = c; + config.interval = 1000; + config.noWrap = true; + // config.keyboard = false; + })); + + it('should initialize inputs with provided config', () => { + const fixture = TestBed.createComponent(CarouselComponent); + fixture.detectChanges(); + + const carousel = fixture.componentInstance; + expect(carousel.interval).toBe(config.interval); + expect(carousel.noWrap).toBe(config.noWrap); + // expect(carousel.keyboard).toBe(config.keyboard); + }); + }); + + describe('Custom config as provider', () => { + const config = new CarouselConfig(); + config.interval = 1000; + config.noWrap = true; + // config.keyboard = false; + + beforeEach(() => { + TestBed.configureTestingModule( + {imports: [CarouselModule.forRoot()], providers: [{provide: CarouselConfig, useValue: config}]}); + }); + + it('should initialize inputs with provided config as provider', () => { + const fixture = TestBed.createComponent(CarouselComponent); + fixture.detectChanges(); + + const carousel = fixture.componentInstance; + expect(carousel.interval).toBe(config.interval); + expect(carousel.noWrap).toBe(config.noWrap); + // expect(carousel.keyboard).toBe(config.keyboard); + }); + }); + +}); + +@Component({selector: 'test-cmp', template: ''}) +class TestComponent { + public activeSlideIndex: number; + // keyboard = true; +} diff --git a/src/spec/ng-bootstrap/utils/linkedlist.spec.ts b/src/spec/utils/linkedlist.spec.ts similarity index 98% rename from src/spec/ng-bootstrap/utils/linkedlist.spec.ts rename to src/spec/utils/linkedlist.spec.ts index 71af9f8bb8..84378b5bbb 100644 --- a/src/spec/ng-bootstrap/utils/linkedlist.spec.ts +++ b/src/spec/utils/linkedlist.spec.ts @@ -1,4 +1,4 @@ -import LinkedList from '../../../utils/linked-list.class'; +import LinkedList from '../../utils/linked-list.class'; let list: LinkedList; diff --git a/src/utils/linked-list.class.ts b/src/utils/linked-list.class.ts index a63b5cddd6..6093df856c 100644 --- a/src/utils/linked-list.class.ts +++ b/src/utils/linked-list.class.ts @@ -32,7 +32,7 @@ export default class LinkedList extends Array { public get(position: number): T { if (this.length === 0 || position < 0 || position >= this.length) { - throw new Error('Position is out of the list'); + return void 0; } let current = this.head;