-
-
+ class="docs-carousel-content-wrapper" role="region">
+
-
+
+
diff --git a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.scss b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.scss
index cfdc975e93a6..2284fa7eaa89 100644
--- a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.scss
+++ b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.scss
@@ -1,24 +1,21 @@
app-carousel {
- display: flex;
- align-items: center;
- justify-content: center;
- margin: 0 40px;
+ display: block;
+ position: relative;
}
.docs-carousel-content {
display: flex;
flex-direction: row;
- overflow: hidden;
outline: none;
+ transition: transform 0.5s ease-in-out;
}
.docs-carousel-content-wrapper {
- position: relative;
+ overflow: hidden;
}
[carousel-item] {
flex-shrink: 0;
- transition: transform 0.5s ease-in-out;
}
.docs-carousel-nav {
@@ -28,9 +25,9 @@ app-carousel {
}
.docs-carousel-nav-prev {
- left: -40px;
+ left: -50px;
}
.docs-carousel-nav-next {
- right: -40px;
+ right: -50px;
}
diff --git a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.spec.ts b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.spec.ts
index cf7db1873f85..1bdf9220fe4f 100644
--- a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.spec.ts
+++ b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.spec.ts
@@ -39,51 +39,17 @@ describe('HorizontalCarousel', () => {
component.next();
fixture.detectChanges();
- expect(component.index).toEqual(1);
-
const navPrevious = fixture.nativeElement.querySelector('.docs-carousel-nav-prev');
expect(navPrevious).toBeDefined();
});
it('should hide next nav arrow after reaching end of items', () => {
- expect(component.visibleItems).toBe(4);
-
component.next();
component.next();
- expect(component.index).toEqual(2);
fixture.detectChanges();
const navPrevious = fixture.nativeElement.querySelector('.docs-carousel-nav-next');
expect(navPrevious).toBeNull();
-
- // in case of keyboard nav at end of items
- component.next();
- expect(component.index).toEqual(2);
- });
-
- it('should resize carousel when not all content can be displayed', () => {
- const carouselWrapper = fixture.nativeElement.querySelector('.docs-carousel-content-wrapper');
- fixture.nativeElement.style.width = '1350px';
- window.dispatchEvent(new Event('resize'));
-
- fixture.detectChanges();
-
- expect(carouselWrapper.clientWidth).toEqual(1250);
- expect(component.visibleItems).toEqual(5);
- });
-
- it('should not resize carousel when all content can be displayed', () => {
- fixture.componentInstance.numberOfItems = 2;
- fixture.detectChanges();
-
- const carouselWrapper = fixture.nativeElement.querySelector('.docs-carousel-content-wrapper');
- fixture.nativeElement.style.width = '1350px';
- window.dispatchEvent(new Event('resize'));
-
- fixture.detectChanges();
-
- expect(carouselWrapper.clientWidth).toEqual(500);
- expect(component.visibleItems).toEqual(2);
});
});
@@ -91,11 +57,16 @@ describe('HorizontalCarousel', () => {
selector: 'test-carousel',
template:
`
-
+
`,
- styles: ['.docs-carousel-item-container { display: flex; }']
+ styles: [`
+ .docs-carousel-item-container {
+ display: flex;
+ width: 250px;
+ }
+ `]
})
class CarouselTestComponent {
numberOfItems = 6;
diff --git a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.ts b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.ts
index 448a088ac3fb..48614e985068 100644
--- a/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.ts
+++ b/material.angular.io/material.angular.io/material.angular.io/material.angular.io/src/app/shared/carousel/carousel.ts
@@ -11,6 +11,7 @@ import {
ViewEncapsulation,
} from '@angular/core';
import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y';
+import {LEFT_ARROW, RIGHT_ARROW, TAB} from '@angular/cdk/keycodes';
@Directive({
@@ -18,11 +19,9 @@ import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y';
})
export class CarouselItem implements FocusableOption {
@HostBinding('attr.role') readonly role = 'listitem';
- @HostBinding('style.width.px') width = this.carousel.itemWidth;
@HostBinding('tabindex') tabindex = '-1';
- constructor(readonly carousel: Carousel, readonly element: ElementRef) {
- }
+ constructor(readonly element: ElementRef) {}
focus(): void {
this.element.nativeElement.focus({preventScroll: true});
@@ -37,148 +36,108 @@ export class CarouselItem implements FocusableOption {
})
export class Carousel implements AfterContentInit {
@Input('aria-label') ariaLabel: string | undefined;
- @Input() itemWidth: number | undefined;
@ContentChildren(CarouselItem) items!: QueryList;
- @ViewChild('contentWrapper') wrapper!: ElementRef;
+ @ViewChild('list') list!: ElementRef;
position = 0;
showPrevArrow = false;
showNextArrow = true;
- visibleItems: number | undefined;
- shiftWidth: number | undefined;
- itemsArray: CarouselItem[] | undefined;
- private focusKeyManager: FocusKeyManager | undefined;
-
- constructor(private readonly element: ElementRef) {}
-
- private _index = 0;
-
- get index(): number {
- return this._index;
- }
-
- set index(i: number) {
- this._index = i;
- let lastVisibleIndex = this.items.length;
- if (this.visibleItems) {
- lastVisibleIndex -= this.visibleItems;
+ index = 0;
+ private _keyManager!: FocusKeyManager;
+
+ onKeydown({keyCode}: KeyboardEvent) {
+ const manager = this._keyManager;
+ const previousActiveIndex = manager.activeItemIndex;
+
+ if (keyCode === LEFT_ARROW) {
+ manager.setPreviousItemActive();
+ } else if (keyCode === RIGHT_ARROW) {
+ manager.setNextItemActive();
+ } else if (keyCode === TAB && !manager.activeItem) {
+ manager.setFirstItemActive();
}
- this.showPrevArrow = i > 0;
- this.showNextArrow = i < lastVisibleIndex;
- }
+ if (manager.activeItemIndex != null && manager.activeItemIndex !== previousActiveIndex) {
+ this.index = manager.activeItemIndex;
+ this._updateItemTabIndices();
- onKeydown(event: KeyboardEvent) {
- if (this.focusKeyManager != null) {
- switch (event.key) {
- case 'Tab':
- if (!this.focusKeyManager.activeItem) {
- this.focusKeyManager.setFirstItemActive();
- this._updateItemTabIndices();
- }
- break;
-
- case 'ArrowLeft':
- if (this.focusKeyManager.activeItemIndex === this.index) {
- this.previous();
- }
- this.focusKeyManager.setPreviousItemActive();
- this._updateItemTabIndices();
- break;
-
- case 'ArrowRight':
- if (this.focusKeyManager.activeItemIndex === this.index + (this.visibleItems || 0) - 1) {
- this.next();
- }
- this.focusKeyManager.setNextItemActive();
- this._updateItemTabIndices();
- break;
-
- default:
- break;
+ if (this._isOutOfView(this.index)) {
+ this._scrollToActiveItem();
}
}
}
- onResize() {
- this._resizeCarousel();
- }
-
ngAfterContentInit(): void {
- this.focusKeyManager =
- new FocusKeyManager(this.items) as FocusKeyManager;
- // timeout to make sure clientWidth is defined
- setTimeout(() => {
- this.itemsArray = this.items.toArray();
- this.shiftWidth = this.calculateShiftWidth(this.itemsArray);
- this._resizeCarousel();
- });
+ this._keyManager = new FocusKeyManager(this.items);
}
+ /** Goes to the next set of items */
next() {
- // prevent keyboard navigation from going out of bounds
- if (this.showNextArrow) {
- this._shiftItems(1);
+ for (let i = this.index; i < this.items.length; i++) {
+ if (this._isOutOfView(i)) {
+ this.index = i;
+ this._scrollToActiveItem();
+ break;
+ }
}
}
+ /** Goes to the previous set of items. */
previous() {
- // prevent keyboard navigation from going out of bounds
- if (this.showPrevArrow) {
- this._shiftItems(-1);
+ for (let i = this.index; i > -1; i--) {
+ if (this._isOutOfView(i)) {
+ this.index = i;
+ this._scrollToActiveItem();
+ break;
+ }
}
}
- /**
- * @param items array of carousel items
- * @return width to shift the carousel
- */
- calculateShiftWidth(items: CarouselItem[]): number {
- return items[0].element.nativeElement.clientWidth;
- }
-
+ /** Updates the `tabindex` of each of the items based on their active state. */
private _updateItemTabIndices() {
this.items.forEach((item: CarouselItem) => {
- if (this.focusKeyManager != null) {
- item.tabindex = item === this.focusKeyManager.activeItem ? '0' : '-1';
+ if (this._keyManager != null) {
+ item.tabindex = item === this._keyManager.activeItem ? '0' : '-1';
}
});
}
- private _shiftItems(shiftIndex: number) {
- this.index += shiftIndex;
- this.position += shiftIndex *
- (this.shiftWidth || this.calculateShiftWidth(this.items.toArray()));
- this.items.forEach((item: CarouselItem) => {
- item.element.nativeElement.style.transform = `translateX(-${this.position}px)`;
- });
- }
+ /** Scrolls an item into the viewport. */
+ private _scrollToActiveItem() {
+ if (!this._isOutOfView(this.index)) {
+ return;
+ }
+
+ const itemsArray = this.items.toArray();
+ let targetItemIndex = this.index;
- private _resizeCarousel() {
- if (this.shiftWidth == null) {
- this.shiftWidth = this.calculateShiftWidth(this.items.toArray());
+ // Only shift the carousel by one if we're going forwards. This
+ // looks better compared to moving the carousel by an entire page.
+ if (this.index > 0 && !this._isOutOfView(this.index - 1)) {
+ targetItemIndex = itemsArray.findIndex((_, i) => !this._isOutOfView(i)) + 1;
}
- const newVisibleItems = Math.max(1, Math.min(
- Math.floor((this.element.nativeElement.offsetWidth) / this.shiftWidth),
- this.items.length));
- if (this.visibleItems !== newVisibleItems) {
- if ((this.visibleItems || 0) < newVisibleItems) {
- const lastVisibleIndex = this.items.length - (this.visibleItems || 0);
- const shiftIndex = this.index - (lastVisibleIndex) + 1;
- if (shiftIndex > 0) {
- this._shiftItems(-shiftIndex);
- }
- } else {
- if (this.focusKeyManager != null) {
- if (this.focusKeyManager.activeItemIndex && this.focusKeyManager.activeItemIndex >
- this.index + newVisibleItems - 1) {
- this.focusKeyManager.setPreviousItemActive();
- this._updateItemTabIndices();
- }
- }
+
+ this.position = itemsArray[targetItemIndex].element.nativeElement.offsetLeft;
+ this.list.nativeElement.style.transform = `translateX(-${this.position}px)`;
+ this.showPrevArrow = this.index > 0;
+ this.showNextArrow = false;
+
+ for (let i = itemsArray.length - 1; i > -1; i--) {
+ if (this._isOutOfView(i, 'end')) {
+ this.showNextArrow = true;
+ break;
}
- this.visibleItems = newVisibleItems;
- this.showNextArrow = this.index < (this.items.length - this.visibleItems);
}
- this.wrapper.nativeElement.style.width = `${this.visibleItems * this.shiftWidth}px`;
+ }
+
+ /** Checks whether an item at a specific index is outside of the viewport. */
+ private _isOutOfView(index: number, side?: 'start' | 'end') {
+ const {offsetWidth, offsetLeft} = this.items.toArray()[index].element.nativeElement;
+
+ if ((!side || side === 'start') && offsetLeft - this.position < 0) {
+ return true;
+ }
+
+ return (!side || side === 'end') &&
+ (offsetWidth + offsetLeft - this.position) > this.list.nativeElement.clientWidth;
}
}