diff --git a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.css b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.css index b25726a6c6d3..e7f8daa5cd3b 100644 --- a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.css +++ b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.css @@ -1,3 +1,4 @@ .example-action-button { - margin-bottom: 8px; + margin-top: 8px; + margin-right: 8px; } diff --git a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.html b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.html index 538f335c1b8d..104c32507d3c 100644 --- a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.html +++ b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.html @@ -1,12 +1,13 @@ - - + + + diff --git a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.ts b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.ts index 330f3d54ff04..12886bf2b915 100644 --- a/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.ts +++ b/src/material-examples/tab-nav-bar-basic/tab-nav-bar-basic-example.ts @@ -16,4 +16,8 @@ export class TabNavBarBasicExample { toggleBackground() { this.background = this.background ? '' : 'primary'; } + + addLink() { + this.links.push(`Link ${this.links.length + 1}`); + } } diff --git a/src/material/tabs/_tabs-common.scss b/src/material/tabs/_tabs-common.scss index a395cddb1271..8a5067d1adc5 100644 --- a/src/material/tabs/_tabs-common.scss +++ b/src/material/tabs/_tabs-common.scss @@ -1,5 +1,6 @@ @import '../core/style/variables'; @import '../core/style/noop-animation'; +@import '../core/style/vendor-prefixes'; @import '../../cdk/a11y/a11y'; $mat-tab-bar-height: 48px !default; @@ -51,14 +52,7 @@ $mat-tab-animation-duration: 500ms !default; } } -// Mixin styles for the top section of the view; contains the tab labels. -@mixin tab-header { - overflow: hidden; - position: relative; - flex-shrink: 0; -} - -// Mixin styles for the ink bar that displays near the active tab in the header. +// The ink bar that displays under the active tab label @mixin ink-bar { $height: 2px; @@ -78,3 +72,85 @@ $mat-tab-animation-duration: 500ms !default; height: 0; } } + +// Structural styles for a tab header. Used by both `mat-tab-header` and `mat-tab-nav-bar`. +@mixin paginated-tab-header { + .mat-tab-header { + display: flex; + overflow: hidden; + position: relative; + flex-shrink: 0; + } + + .mat-tab-header-pagination { + @include user-select(none); + position: relative; + display: none; + justify-content: center; + align-items: center; + min-width: 32px; + cursor: pointer; + z-index: 2; + -webkit-tap-highlight-color: transparent; + touch-action: none; + + .mat-tab-header-pagination-controls-enabled & { + display: flex; + } + } + + // The pagination control that is displayed on the left side of the tab header. + .mat-tab-header-pagination-before, .mat-tab-header-rtl .mat-tab-header-pagination-after { + padding-left: 4px; + .mat-tab-header-pagination-chevron { + transform: rotate(-135deg); + } + } + + // The pagination control that is displayed on the right side of the tab header. + .mat-tab-header-rtl .mat-tab-header-pagination-before, .mat-tab-header-pagination-after { + padding-right: 4px; + .mat-tab-header-pagination-chevron { + transform: rotate(45deg); + } + } + + .mat-tab-header-pagination-chevron { + border-style: solid; + border-width: 2px 2px 0 0; + content: ''; + height: 8px; + width: 8px; + } + + .mat-tab-header-pagination-disabled { + box-shadow: none; + cursor: default; + } + + .mat-tab-list { + flex-grow: 1; + position: relative; + transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1); + } +} + +// Structural styles for the element that wraps the paginated header items. +@mixin paginated-tab-header-item-wrapper { + display: flex; + + [mat-align-tabs='center'] & { + justify-content: center; + } + + [mat-align-tabs='end'] & { + justify-content: flex-end; + } +} + +@mixin paginated-tab-header-container { + display: flex; + flex-grow: 1; + overflow: hidden; + z-index: 1; +} diff --git a/src/material/tabs/_tabs-theme.scss b/src/material/tabs/_tabs-theme.scss index 65afdc75c188..15d1d4ddf865 100644 --- a/src/material/tabs/_tabs-theme.scss +++ b/src/material/tabs/_tabs-theme.scss @@ -96,7 +96,7 @@ @mixin _mat-tabs-background($background-color) { // Set background color for the tab group - .mat-tab-header, .mat-tab-links { + .mat-tab-header, .mat-tab-links, .mat-tab-header-pagination { background-color: mat-color($background-color); } diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts new file mode 100644 index 000000000000..186a30da0e29 --- /dev/null +++ b/src/material/tabs/paginated-tab-header.ts @@ -0,0 +1,556 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectorRef, + ElementRef, + NgZone, + Optional, + QueryList, + EventEmitter, + AfterContentChecked, + AfterContentInit, + AfterViewInit, + OnDestroy, +} from '@angular/core'; +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {coerceNumberProperty} from '@angular/cdk/coercion'; +import {ViewportRuler} from '@angular/cdk/scrolling'; +import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y'; +import {END, ENTER, HOME, SPACE, hasModifierKey} from '@angular/cdk/keycodes'; +import {merge, of as observableOf, Subject, timer, fromEvent} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {MatInkBar} from './ink-bar'; +import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; + + +/** Config used to bind passive event listeners */ +const passiveEventListenerOptions = + normalizePassiveListenerOptions({passive: true}) as EventListenerOptions; + +/** + * The directions that scrolling can go in when the header's tabs exceed the header width. 'After' + * will scroll the header towards the end of the tabs list and 'before' will scroll towards the + * beginning of the list. + */ +export type ScrollDirection = 'after' | 'before'; + +/** + * The distance in pixels that will be overshot when scrolling a tab label into view. This helps + * provide a small affordance to the label next to it. + */ +const EXAGGERATED_OVERSCROLL = 60; + +/** + * Amount of milliseconds to wait before starting to scroll the header automatically. + * Set a little conservatively in order to handle fake events dispatched on touch devices. + */ +const HEADER_SCROLL_DELAY = 650; + +/** + * Interval in milliseconds at which to scroll the header + * while the user is holding their pointer. + */ +const HEADER_SCROLL_INTERVAL = 100; + +/** Item inside a paginated tab header. */ +type MatPaginatedTabHeaderItem = FocusableOption & {elementRef: ElementRef}; + +/** + * Base class for a tab header that supported pagination. + */ +export abstract class MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, + AfterViewInit, OnDestroy { + abstract _items: QueryList; + abstract _inkBar: MatInkBar; + abstract _tabListContainer: ElementRef; + abstract _tabList: ElementRef; + abstract _nextPaginator: ElementRef; + abstract _previousPaginator: ElementRef; + + /** The distance in pixels that the tab labels should be translated to the left. */ + private _scrollDistance = 0; + + /** Whether the header should scroll to the selected index after the view has been checked. */ + private _selectedIndexChanged = false; + + /** Emits when the component is destroyed. */ + private readonly _destroyed = new Subject(); + + /** Whether the controls for pagination should be displayed */ + _showPaginationControls = false; + + /** Whether the tab list can be scrolled more towards the end of the tab label list. */ + _disableScrollAfter = true; + + /** Whether the tab list can be scrolled more towards the beginning of the tab label list. */ + _disableScrollBefore = true; + + /** + * The number of tab labels that are displayed on the header. When this changes, the header + * should re-evaluate the scroll position. + */ + private _tabLabelCount: number; + + /** Whether the scroll distance has changed and should be applied after the view is checked. */ + private _scrollDistanceChanged: boolean; + + /** Used to manage focus between the tabs. */ + private _keyManager: FocusKeyManager; + + /** Cached text content of the header. */ + private _currentTextContent: string; + + /** Stream that will stop the automated scrolling. */ + private _stopScrolling = new Subject(); + + /** The index of the active tab. */ + get selectedIndex(): number { return this._selectedIndex; } + set selectedIndex(value: number) { + value = coerceNumberProperty(value); + + if (this._selectedIndex != value) { + this._selectedIndexChanged = true; + this._selectedIndex = value; + + if (this._keyManager) { + this._keyManager.updateActiveItemIndex(value); + } + } + } + private _selectedIndex: number = 0; + + /** Event emitted when the option is selected. */ + readonly selectFocusedIndex: EventEmitter = new EventEmitter(); + + /** Event emitted when a label is focused. */ + readonly indexFocused: EventEmitter = new EventEmitter(); + + constructor(protected _elementRef: ElementRef, + protected _changeDetectorRef: ChangeDetectorRef, + private _viewportRuler: ViewportRuler, + @Optional() private _dir: Directionality, + private _ngZone: NgZone, + /** + * @deprecated @breaking-change 9.0.0 `_platform` and `_animationMode` + * parameters to become required. + */ + private _platform?: Platform, + public _animationMode?: string) { + + // Bind the `mouseleave` event on the outside since it doesn't change anything in the view. + _ngZone.runOutsideAngular(() => { + fromEvent(_elementRef.nativeElement, 'mouseleave') + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._stopInterval(); + }); + }); + } + + /** Called when the user has selected an item via the keyboard. */ + protected abstract _itemSelected(event: KeyboardEvent): void; + + ngAfterViewInit() { + // We need to handle these events manually, because we want to bind passive event listeners. + fromEvent(this._previousPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._handlePaginatorPress('before'); + }); + + fromEvent(this._nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._handlePaginatorPress('after'); + }); + } + + ngAfterContentInit() { + const dirChange = this._dir ? this._dir.change : observableOf(null); + const resize = this._viewportRuler.change(150); + const realign = () => { + this.updatePagination(); + this._alignInkBarToSelectedTab(); + }; + + this._keyManager = new FocusKeyManager(this._items) + .withHorizontalOrientation(this._getLayoutDirection()) + .withWrap(); + + this._keyManager.updateActiveItem(0); + + // Defer the first call in order to allow for slower browsers to lay out the elements. + // This helps in cases where the user lands directly on a page with paginated tabs. + typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(realign) : realign(); + + // On dir change or window resize, realign the ink bar and update the orientation of + // the key manager if the direction has changed. + merge(dirChange, resize, this._items.changes).pipe(takeUntil(this._destroyed)).subscribe(() => { + realign(); + this._keyManager.withHorizontalOrientation(this._getLayoutDirection()); + }); + + // If there is a change in the focus key manager we need to emit the `indexFocused` + // event in order to provide a public event that notifies about focus changes. Also we realign + // the tabs container by scrolling the new focused tab into the visible section. + this._keyManager.change.pipe(takeUntil(this._destroyed)).subscribe(newFocusIndex => { + this.indexFocused.emit(newFocusIndex); + this._setTabFocus(newFocusIndex); + }); + } + + ngAfterContentChecked(): void { + // If the number of tab labels have changed, check if scrolling should be enabled + if (this._tabLabelCount != this._items.length) { + this.updatePagination(); + this._tabLabelCount = this._items.length; + this._changeDetectorRef.markForCheck(); + } + + // If the selected index has changed, scroll to the label and check if the scrolling controls + // should be disabled. + if (this._selectedIndexChanged) { + this._scrollToLabel(this._selectedIndex); + this._checkScrollingControls(); + this._alignInkBarToSelectedTab(); + this._selectedIndexChanged = false; + this._changeDetectorRef.markForCheck(); + } + + // If the scroll distance has been changed (tab selected, focused, scroll controls activated), + // then translate the header to reflect this. + if (this._scrollDistanceChanged) { + this._updateTabScrollPosition(); + this._scrollDistanceChanged = false; + this._changeDetectorRef.markForCheck(); + } + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + this._stopScrolling.complete(); + } + + /** Handles keyboard events on the header. */ + _handleKeydown(event: KeyboardEvent) { + // We don't handle any key bindings with a modifier key. + if (hasModifierKey(event)) { + return; + } + + switch (event.keyCode) { + case HOME: + this._keyManager.setFirstItemActive(); + event.preventDefault(); + break; + case END: + this._keyManager.setLastItemActive(); + event.preventDefault(); + break; + case ENTER: + case SPACE: + this.selectFocusedIndex.emit(this.focusIndex); + this._itemSelected(event); + break; + default: + this._keyManager.onKeydown(event); + } + } + + /** + * Callback for when the MutationObserver detects that the content has changed. + */ + _onContentChanges() { + const textContent = this._elementRef.nativeElement.textContent; + + // We need to diff the text content of the header, because the MutationObserver callback + // will fire even if the text content didn't change which is inefficient and is prone + // to infinite loops if a poorly constructed expression is passed in (see #14249). + if (textContent !== this._currentTextContent) { + this._currentTextContent = textContent || ''; + + // The content observer runs outside the `NgZone` by default, which + // means that we need to bring the callback back in ourselves. + this._ngZone.run(() => { + this.updatePagination(); + this._alignInkBarToSelectedTab(); + this._changeDetectorRef.markForCheck(); + }); + } + } + + /** + * Updates the view whether pagination should be enabled or not. + * + * WARNING: Calling this method can be very costly in terms of performance. It should be called + * as infrequently as possible from outside of the Tabs component as it causes a reflow of the + * page. + */ + updatePagination() { + this._checkPaginationEnabled(); + this._checkScrollingControls(); + this._updateTabScrollPosition(); + } + + /** Tracks which element has focus; used for keyboard navigation */ + get focusIndex(): number { + return this._keyManager ? this._keyManager.activeItemIndex! : 0; + } + + /** When the focus index is set, we must manually send focus to the correct label */ + set focusIndex(value: number) { + if (!this._isValidIndex(value) || this.focusIndex === value || !this._keyManager) { + return; + } + + this._keyManager.setActiveItem(value); + } + + /** + * Determines if an index is valid. If the tabs are not ready yet, we assume that the user is + * providing a valid index and return true. + */ + _isValidIndex(index: number): boolean { + if (!this._items) { return true; } + + const tab = this._items ? this._items.toArray()[index] : null; + return !!tab && !tab.disabled; + } + + /** + * Sets focus on the HTML element for the label wrapper and scrolls it into the view if + * scrolling is enabled. + */ + _setTabFocus(tabIndex: number) { + if (this._showPaginationControls) { + this._scrollToLabel(tabIndex); + } + + if (this._items && this._items.length) { + this._items.toArray()[tabIndex].focus(); + + // Do not let the browser manage scrolling to focus the element, this will be handled + // by using translation. In LTR, the scroll left should be 0. In RTL, the scroll width + // should be the full width minus the offset width. + const containerEl = this._tabListContainer.nativeElement; + const dir = this._getLayoutDirection(); + + if (dir == 'ltr') { + containerEl.scrollLeft = 0; + } else { + containerEl.scrollLeft = containerEl.scrollWidth - containerEl.offsetWidth; + } + } + } + + /** The layout direction of the containing app. */ + _getLayoutDirection(): Direction { + return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; + } + + /** Performs the CSS transformation on the tab list that will cause the list to scroll. */ + _updateTabScrollPosition() { + const scrollDistance = this.scrollDistance; + const platform = this._platform; + const translateX = this._getLayoutDirection() === 'ltr' ? -scrollDistance : scrollDistance; + + // Don't use `translate3d` here because we don't want to create a new layer. A new layer + // seems to cause flickering and overflow in Internet Explorer. For example, the ink bar + // and ripples will exceed the boundaries of the visible tab bar. + // See: https://github.com/angular/components/issues/10276 + // We round the `transform` here, because transforms with sub-pixel precision cause some + // browsers to blur the content of the element. + this._tabList.nativeElement.style.transform = `translateX(${Math.round(translateX)}px)`; + + // Setting the `transform` on IE will change the scroll offset of the parent, causing the + // position to be thrown off in some cases. We have to reset it ourselves to ensure that + // it doesn't get thrown off. Note that we scope it only to IE and Edge, because messing + // with the scroll position throws off Chrome 71+ in RTL mode (see #14689). + // @breaking-change 9.0.0 Remove null check for `platform` after it can no longer be undefined. + if (platform && (platform.TRIDENT || platform.EDGE)) { + this._tabListContainer.nativeElement.scrollLeft = 0; + } + } + + /** Sets the distance in pixels that the tab header should be transformed in the X-axis. */ + get scrollDistance(): number { return this._scrollDistance; } + set scrollDistance(value: number) { + this._scrollTo(value); + } + + /** + * Moves the tab list in the 'before' or 'after' direction (towards the beginning of the list or + * the end of the list, respectively). The distance to scroll is computed to be a third of the + * length of the tab list view window. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _scrollHeader(direction: ScrollDirection) { + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + + // Move the scroll distance one-third the length of the tab list's viewport. + const scrollAmount = (direction == 'before' ? -1 : 1) * viewLength / 3; + + return this._scrollTo(this._scrollDistance + scrollAmount); + } + + /** Handles click events on the pagination arrows. */ + _handlePaginatorClick(direction: ScrollDirection) { + this._stopInterval(); + this._scrollHeader(direction); + } + + /** + * Moves the tab list such that the desired tab label (marked by index) is moved into view. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _scrollToLabel(labelIndex: number) { + const selectedLabel = this._items ? this._items.toArray()[labelIndex] : null; + + if (!selectedLabel) { return; } + + // The view length is the visible width of the tab labels. + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + const {offsetLeft, offsetWidth} = selectedLabel.elementRef.nativeElement; + + let labelBeforePos: number, labelAfterPos: number; + if (this._getLayoutDirection() == 'ltr') { + labelBeforePos = offsetLeft; + labelAfterPos = labelBeforePos + offsetWidth; + } else { + labelAfterPos = this._tabList.nativeElement.offsetWidth - offsetLeft; + labelBeforePos = labelAfterPos - offsetWidth; + } + + const beforeVisiblePos = this.scrollDistance; + const afterVisiblePos = this.scrollDistance + viewLength; + + if (labelBeforePos < beforeVisiblePos) { + // Scroll header to move label to the before direction + this.scrollDistance -= beforeVisiblePos - labelBeforePos + EXAGGERATED_OVERSCROLL; + } else if (labelAfterPos > afterVisiblePos) { + // Scroll header to move label to the after direction + this.scrollDistance += labelAfterPos - afterVisiblePos + EXAGGERATED_OVERSCROLL; + } + } + + /** + * Evaluate whether the pagination controls should be displayed. If the scroll width of the + * tab list is wider than the size of the header container, then the pagination controls should + * be shown. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _checkPaginationEnabled() { + const isEnabled = + this._tabList.nativeElement.scrollWidth > this._elementRef.nativeElement.offsetWidth; + + if (!isEnabled) { + this.scrollDistance = 0; + } + + if (isEnabled !== this._showPaginationControls) { + this._changeDetectorRef.markForCheck(); + } + + this._showPaginationControls = isEnabled; + } + + /** + * Evaluate whether the before and after controls should be enabled or disabled. + * If the header is at the beginning of the list (scroll distance is equal to 0) then disable the + * before button. If the header is at the end of the list (scroll distance is equal to the + * maximum distance we can scroll), then disable the after button. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _checkScrollingControls() { + // Check if the pagination arrows should be activated. + this._disableScrollBefore = this.scrollDistance == 0; + this._disableScrollAfter = this.scrollDistance == this._getMaxScrollDistance(); + this._changeDetectorRef.markForCheck(); + } + + /** + * Determines what is the maximum length in pixels that can be set for the scroll distance. This + * is equal to the difference in width between the tab list container and tab header container. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _getMaxScrollDistance(): number { + const lengthOfTabList = this._tabList.nativeElement.scrollWidth; + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + return (lengthOfTabList - viewLength) || 0; + } + + /** Tells the ink-bar to align itself to the current label wrapper */ + _alignInkBarToSelectedTab(): void { + const selectedItem = this._items && this._items.length ? + this._items.toArray()[this.selectedIndex] : null; + const selectedLabelWrapper = selectedItem ? selectedItem.elementRef.nativeElement : null; + + if (selectedLabelWrapper) { + this._inkBar.alignToElement(selectedLabelWrapper); + } else { + this._inkBar.hide(); + } + } + + /** Stops the currently-running paginator interval. */ + _stopInterval() { + this._stopScrolling.next(); + } + + /** + * Handles the user pressing down on one of the paginators. + * Starts scrolling the header after a certain amount of time. + * @param direction In which direction the paginator should be scrolled. + */ + _handlePaginatorPress(direction: ScrollDirection) { + // Avoid overlapping timers. + this._stopInterval(); + + // Start a timer after the delay and keep firing based on the interval. + timer(HEADER_SCROLL_DELAY, HEADER_SCROLL_INTERVAL) + // Keep the timer going until something tells it to stop or the component is destroyed. + .pipe(takeUntil(merge(this._stopScrolling, this._destroyed))) + .subscribe(() => { + const {maxScrollDistance, distance} = this._scrollHeader(direction); + + // Stop the timer if we've reached the start or the end. + if (distance === 0 || distance >= maxScrollDistance) { + this._stopInterval(); + } + }); + } + + /** + * Scrolls the header to a given position. + * @param position Position to which to scroll. + * @returns Information on the current scroll distance and the maximum. + */ + private _scrollTo(position: number) { + const maxScrollDistance = this._getMaxScrollDistance(); + this._scrollDistance = Math.max(0, Math.min(maxScrollDistance, position)); + + // Mark that the scroll distance has changed so that after the view is checked, the CSS + // transformation can move the header. + this._scrollDistanceChanged = true; + this._checkScrollingControls(); + + return {maxScrollDistance, distance: this._scrollDistance}; + } +} diff --git a/src/material/tabs/public-api.ts b/src/material/tabs/public-api.ts index 60b7fb6a9221..b8865e5539a7 100644 --- a/src/material/tabs/public-api.ts +++ b/src/material/tabs/public-api.ts @@ -15,10 +15,11 @@ export { MatTabBodyPositionState, MatTabBodyPortal } from './tab-body'; -export {MatTabHeader, ScrollDirection} from './tab-header'; +export {MatTabHeader} from './tab-header'; export {MatTabLabelWrapper} from './tab-label-wrapper'; export {MatTab} from './tab'; export {MatTabLabel} from './tab-label'; export {MatTabNav, MatTabLink} from './tab-nav-bar/index'; export {MatTabContent} from './tab-content'; +export {ScrollDirection} from './paginated-tab-header'; export * from './tabs-animations'; diff --git a/src/material/tabs/tab-header.html b/src/material/tabs/tab-header.html index bc64b0c0bae8..39abcf94cd2c 100644 --- a/src/material/tabs/tab-header.html +++ b/src/material/tabs/tab-header.html @@ -9,12 +9,11 @@
-
+
diff --git a/src/material/tabs/tab-header.scss b/src/material/tabs/tab-header.scss index 2ff126ff163e..be368ccd25a4 100644 --- a/src/material/tabs/tab-header.scss +++ b/src/material/tabs/tab-header.scss @@ -1,99 +1,32 @@ -@import '../core/style/variables'; -@import '../core/style/layout-common'; -@import '../core/style/vendor-prefixes'; @import '../core/style/noop-animation'; @import './tabs-common'; -.mat-tab-header { - display: flex; - @include tab-header; -} - -// Wraps each tab label -.mat-tab-label { - @include tab-label; - position: relative; -} - -@media ($mat-xsmall) { - .mat-tab-label { - min-width: 72px; - } -} +@include paginated-tab-header; -// The ink bar that displays under the active tab label .mat-ink-bar { @include ink-bar; } -.mat-tab-header-pagination { - @include user-select(none); - position: relative; - display: none; - justify-content: center; - align-items: center; - min-width: 32px; - cursor: pointer; - z-index: 2; - -webkit-tap-highlight-color: transparent; - touch-action: none; - - .mat-tab-header-pagination-controls-enabled & { - display: flex; - } -} - -// The pagination control that is displayed on the left side of the tab header. -.mat-tab-header-pagination-before, .mat-tab-header-rtl .mat-tab-header-pagination-after { - padding-left: 4px; - .mat-tab-header-pagination-chevron { - transform: rotate(-135deg); - } -} - -// The pagination control that is displayed on the right side of the tab header. -.mat-tab-header-rtl .mat-tab-header-pagination-before, .mat-tab-header-pagination-after { - padding-right: 4px; - .mat-tab-header-pagination-chevron { - transform: rotate(45deg); - } -} - -.mat-tab-header-pagination-chevron { - border-style: solid; - border-width: 2px 2px 0 0; - content: ''; - height: 8px; - width: 8px; -} - -.mat-tab-header-pagination-disabled { - box-shadow: none; - cursor: default; +.mat-tab-labels { + @include paginated-tab-header-item-wrapper; } .mat-tab-label-container { - display: flex; - flex-grow: 1; - overflow: hidden; - z-index: 1; + @include paginated-tab-header-container; } .mat-tab-list { @include _noop-animation(); - flex-grow: 1; - position: relative; - transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1); } -.mat-tab-labels { - display: flex; - - [mat-align-tabs='center'] & { - justify-content: center; - } +// Wraps each tab label +.mat-tab-label { + @include tab-label; + position: relative; +} - [mat-align-tabs='end'] & { - justify-content: flex-end; +@media ($mat-xsmall) { + .mat-tab-label { + min-width: 72px; } } diff --git a/src/material/tabs/tab-header.ts b/src/material/tabs/tab-header.ts index f242fa2076c6..52676efdd59f 100644 --- a/src/material/tabs/tab-header.ts +++ b/src/material/tabs/tab-header.ts @@ -6,9 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Direction, Directionality} from '@angular/cdk/bidi'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; -import {END, ENTER, HOME, SPACE, hasModifierKey} from '@angular/cdk/keycodes'; +import {Directionality} from '@angular/cdk/bidi'; import {ViewportRuler} from '@angular/cdk/scrolling'; import { AfterContentChecked, @@ -18,62 +16,22 @@ import { Component, ContentChildren, ElementRef, - EventEmitter, - Input, NgZone, - Inject, OnDestroy, Optional, - Output, QueryList, ViewChild, ViewEncapsulation, AfterViewInit, + Input, + Inject, } from '@angular/core'; -import {CanDisableRipple, CanDisableRippleCtor, mixinDisableRipple} from '@angular/material/core'; -import {merge, of as observableOf, Subject, timer, fromEvent} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {MatInkBar} from './ink-bar'; import {MatTabLabelWrapper} from './tab-label-wrapper'; -import {FocusKeyManager} from '@angular/cdk/a11y'; -import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; -import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; - - -/** Config used to bind passive event listeners */ -const passiveEventListenerOptions = - normalizePassiveListenerOptions({passive: true}) as EventListenerOptions; - -/** - * The directions that scrolling can go in when the header's tabs exceed the header width. 'After' - * will scroll the header towards the end of the tabs list and 'before' will scroll towards the - * beginning of the list. - */ -export type ScrollDirection = 'after' | 'before'; - -/** - * The distance in pixels that will be overshot when scrolling a tab label into view. This helps - * provide a small affordance to the label next to it. - */ -const EXAGGERATED_OVERSCROLL = 60; - -/** - * Amount of milliseconds to wait before starting to scroll the header automatically. - * Set a little conservatively in order to handle fake events dispatched on touch devices. - */ -const HEADER_SCROLL_DELAY = 650; - -/** - * Interval in milliseconds at which to scroll the header - * while the user is holding their pointer. - */ -const HEADER_SCROLL_INTERVAL = 100; - -// Boilerplate for applying mixins to MatTabHeader. -/** @docs-private */ -class MatTabHeaderBase {} -const _MatTabHeaderMixinBase: CanDisableRippleCtor & typeof MatTabHeaderBase = - mixinDisableRipple(MatTabHeaderBase); +import {Platform} from '@angular/cdk/platform'; +import {MatPaginatedTabHeader} from './paginated-tab-header'; /** * The header of the tab group which displays a list of all the tabs in the tab group. Includes @@ -87,7 +45,8 @@ const _MatTabHeaderMixinBase: CanDisableRippleCtor & typeof MatTabHeaderBase = selector: 'mat-tab-header', templateUrl: 'tab-header.html', styleUrls: ['tab-header.css'], - inputs: ['disableRipple'], + inputs: ['selectedIndex'], + outputs: ['selectFocusedIndex', 'indexFocused'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -96,484 +55,34 @@ const _MatTabHeaderMixinBase: CanDisableRippleCtor & typeof MatTabHeaderBase = '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", }, }) -export class MatTabHeader extends _MatTabHeaderMixinBase - implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy, CanDisableRipple { +export class MatTabHeader extends MatPaginatedTabHeader implements AfterContentChecked, + AfterContentInit, AfterViewInit, OnDestroy { - @ContentChildren(MatTabLabelWrapper) _labelWrappers: QueryList; + @ContentChildren(MatTabLabelWrapper) _items: QueryList; @ViewChild(MatInkBar, {static: true}) _inkBar: MatInkBar; @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; @ViewChild('tabList', {static: true}) _tabList: ElementRef; @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; - /** The distance in pixels that the tab labels should be translated to the left. */ - private _scrollDistance = 0; - - /** Whether the header should scroll to the selected index after the view has been checked. */ - private _selectedIndexChanged = false; - - /** Emits when the component is destroyed. */ - private readonly _destroyed = new Subject(); - - /** Whether the controls for pagination should be displayed */ - _showPaginationControls = false; - - /** Whether the tab list can be scrolled more towards the end of the tab label list. */ - _disableScrollAfter = true; - - /** Whether the tab list can be scrolled more towards the beginning of the tab label list. */ - _disableScrollBefore = true; - - /** - * The number of tab labels that are displayed on the header. When this changes, the header - * should re-evaluate the scroll position. - */ - private _tabLabelCount: number; - - /** Whether the scroll distance has changed and should be applied after the view is checked. */ - private _scrollDistanceChanged: boolean; - - /** Used to manage focus between the tabs. */ - private _keyManager: FocusKeyManager; - - /** Cached text content of the header. */ - private _currentTextContent: string; - - /** Stream that will stop the automated scrolling. */ - private _stopScrolling = new Subject(); - - /** The index of the active tab. */ + /** Whether the ripple effect is disabled or not. */ @Input() - get selectedIndex(): number { return this._selectedIndex; } - set selectedIndex(value: number) { - value = coerceNumberProperty(value); - this._selectedIndexChanged = this._selectedIndex != value; - this._selectedIndex = value; - - if (this._keyManager) { - this._keyManager.updateActiveItemIndex(value); - } - } - private _selectedIndex: number = 0; - - /** Event emitted when the option is selected. */ - @Output() readonly selectFocusedIndex: EventEmitter = new EventEmitter(); - - /** Event emitted when a label is focused. */ - @Output() readonly indexFocused: EventEmitter = new EventEmitter(); - - constructor(private _elementRef: ElementRef, - private _changeDetectorRef: ChangeDetectorRef, - private _viewportRuler: ViewportRuler, - @Optional() private _dir: Directionality, - private _ngZone: NgZone, - private _platform: Platform, + get disableRipple() { return this._disableRipple; } + set disableRipple(value: any) { this._disableRipple = coerceBooleanProperty(value); } + private _disableRipple: boolean = false; + + constructor(elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + viewportRuler: ViewportRuler, + @Optional() dir: Directionality, + ngZone: NgZone, + platform: Platform, // @breaking-change 9.0.0 `_animationMode` parameter to be made required. - @Optional() @Inject(ANIMATION_MODULE_TYPE) public _animationMode?: string) { - super(); - - // Bind the `mouseleave` event on the outside since it doesn't change anything in the view. - _ngZone.runOutsideAngular(() => { - fromEvent(_elementRef.nativeElement, 'mouseleave') - .pipe(takeUntil(this._destroyed)) - .subscribe(() => { - this._stopInterval(); - }); - }); - } - - ngAfterContentChecked(): void { - // If the number of tab labels have changed, check if scrolling should be enabled - if (this._tabLabelCount != this._labelWrappers.length) { - this.updatePagination(); - this._tabLabelCount = this._labelWrappers.length; - this._changeDetectorRef.markForCheck(); - } - - // If the selected index has changed, scroll to the label and check if the scrolling controls - // should be disabled. - if (this._selectedIndexChanged) { - this._scrollToLabel(this._selectedIndex); - this._checkScrollingControls(); - this._alignInkBarToSelectedTab(); - this._selectedIndexChanged = false; - this._changeDetectorRef.markForCheck(); - } - - // If the scroll distance has been changed (tab selected, focused, scroll controls activated), - // then translate the header to reflect this. - if (this._scrollDistanceChanged) { - this._updateTabScrollPosition(); - this._scrollDistanceChanged = false; - this._changeDetectorRef.markForCheck(); - } - } - - /** Handles keyboard events on the header. */ - _handleKeydown(event: KeyboardEvent) { - // We don't handle any key bindings with a modifier key. - if (hasModifierKey(event)) { - return; - } - - switch (event.keyCode) { - case HOME: - this._keyManager.setFirstItemActive(); - event.preventDefault(); - break; - case END: - this._keyManager.setLastItemActive(); - event.preventDefault(); - break; - case ENTER: - case SPACE: - this.selectFocusedIndex.emit(this.focusIndex); - event.preventDefault(); - break; - default: - this._keyManager.onKeydown(event); - } + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, changeDetectorRef, viewportRuler, dir, ngZone, platform, animationMode); } - /** - * Aligns the ink bar to the selected tab on load. - */ - ngAfterContentInit() { - const dirChange = this._dir ? this._dir.change : observableOf(null); - const resize = this._viewportRuler.change(150); - const realign = () => { - this.updatePagination(); - this._alignInkBarToSelectedTab(); - }; - - this._keyManager = new FocusKeyManager(this._labelWrappers) - .withHorizontalOrientation(this._getLayoutDirection()) - .withWrap(); - - this._keyManager.updateActiveItem(0); - - // Defer the first call in order to allow for slower browsers to lay out the elements. - // This helps in cases where the user lands directly on a page with paginated tabs. - typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(realign) : realign(); - - // On dir change or window resize, realign the ink bar and update the orientation of - // the key manager if the direction has changed. - merge(dirChange, resize).pipe(takeUntil(this._destroyed)).subscribe(() => { - realign(); - this._keyManager.withHorizontalOrientation(this._getLayoutDirection()); - }); - - // If there is a change in the focus key manager we need to emit the `indexFocused` - // event in order to provide a public event that notifies about focus changes. Also we realign - // the tabs container by scrolling the new focused tab into the visible section. - this._keyManager.change.pipe(takeUntil(this._destroyed)).subscribe(newFocusIndex => { - this.indexFocused.emit(newFocusIndex); - this._setTabFocus(newFocusIndex); - }); - } - - ngAfterViewInit() { - // We need to handle these events manually, because we want to bind passive event listeners. - fromEvent(this._previousPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) - .pipe(takeUntil(this._destroyed)) - .subscribe(() => { - this._handlePaginatorPress('before'); - }); - - fromEvent(this._nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) - .pipe(takeUntil(this._destroyed)) - .subscribe(() => { - this._handlePaginatorPress('after'); - }); - } - - ngOnDestroy() { - this._destroyed.next(); - this._destroyed.complete(); - this._stopScrolling.complete(); - } - - /** - * Callback for when the MutationObserver detects that the content has changed. - */ - _onContentChanges() { - const textContent = this._elementRef.nativeElement.textContent; - - // We need to diff the text content of the header, because the MutationObserver callback - // will fire even if the text content didn't change which is inefficient and is prone - // to infinite loops if a poorly constructed expression is passed in (see #14249). - if (textContent !== this._currentTextContent) { - this._currentTextContent = textContent; - - // The content observer runs outside the `NgZone` by default, which - // means that we need to bring the callback back in ourselves. - this._ngZone.run(() => { - this.updatePagination(); - this._alignInkBarToSelectedTab(); - this._changeDetectorRef.markForCheck(); - }); - } - } - - /** - * Updates the view whether pagination should be enabled or not. - * - * WARNING: Calling this method can be very costly in terms of performance. It should be called - * as infrequently as possible from outside of the Tabs component as it causes a reflow of the - * page. - */ - updatePagination() { - this._checkPaginationEnabled(); - this._checkScrollingControls(); - this._updateTabScrollPosition(); - } - - /** Tracks which element has focus; used for keyboard navigation */ - get focusIndex(): number { - return this._keyManager ? this._keyManager.activeItemIndex! : 0; - } - - /** When the focus index is set, we must manually send focus to the correct label */ - set focusIndex(value: number) { - if (!this._isValidIndex(value) || this.focusIndex === value || !this._keyManager) { - return; - } - - this._keyManager.setActiveItem(value); - } - - /** - * Determines if an index is valid. If the tabs are not ready yet, we assume that the user is - * providing a valid index and return true. - */ - _isValidIndex(index: number): boolean { - if (!this._labelWrappers) { return true; } - - const tab = this._labelWrappers ? this._labelWrappers.toArray()[index] : null; - return !!tab && !tab.disabled; - } - - /** - * Sets focus on the HTML element for the label wrapper and scrolls it into the view if - * scrolling is enabled. - */ - _setTabFocus(tabIndex: number) { - if (this._showPaginationControls) { - this._scrollToLabel(tabIndex); - } - - if (this._labelWrappers && this._labelWrappers.length) { - this._labelWrappers.toArray()[tabIndex].focus(); - - // Do not let the browser manage scrolling to focus the element, this will be handled - // by using translation. In LTR, the scroll left should be 0. In RTL, the scroll width - // should be the full width minus the offset width. - const containerEl = this._tabListContainer.nativeElement; - const dir = this._getLayoutDirection(); - - if (dir == 'ltr') { - containerEl.scrollLeft = 0; - } else { - containerEl.scrollLeft = containerEl.scrollWidth - containerEl.offsetWidth; - } - } - } - - /** The layout direction of the containing app. */ - _getLayoutDirection(): Direction { - return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; - } - - /** Performs the CSS transformation on the tab list that will cause the list to scroll. */ - _updateTabScrollPosition() { - const scrollDistance = this.scrollDistance; - const platform = this._platform; - const translateX = this._getLayoutDirection() === 'ltr' ? -scrollDistance : scrollDistance; - - // Don't use `translate3d` here because we don't want to create a new layer. A new layer - // seems to cause flickering and overflow in Internet Explorer. For example, the ink bar - // and ripples will exceed the boundaries of the visible tab bar. - // See: https://github.com/angular/components/issues/10276 - // We round the `transform` here, because transforms with sub-pixel precision cause some - // browsers to blur the content of the element. - this._tabList.nativeElement.style.transform = `translateX(${Math.round(translateX)}px)`; - - // Setting the `transform` on IE will change the scroll offset of the parent, causing the - // position to be thrown off in some cases. We have to reset it ourselves to ensure that - // it doesn't get thrown off. Note that we scope it only to IE and Edge, because messing - // with the scroll position throws off Chrome 71+ in RTL mode (see #14689). - if (platform.TRIDENT || platform.EDGE) { - this._tabListContainer.nativeElement.scrollLeft = 0; - } - } - - /** Sets the distance in pixels that the tab header should be transformed in the X-axis. */ - get scrollDistance(): number { return this._scrollDistance; } - set scrollDistance(value: number) { - this._scrollTo(value); - } - - /** - * Moves the tab list in the 'before' or 'after' direction (towards the beginning of the list or - * the end of the list, respectively). The distance to scroll is computed to be a third of the - * length of the tab list view window. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - _scrollHeader(direction: ScrollDirection) { - const viewLength = this._tabListContainer.nativeElement.offsetWidth; - - // Move the scroll distance one-third the length of the tab list's viewport. - const scrollAmount = (direction == 'before' ? -1 : 1) * viewLength / 3; - - return this._scrollTo(this._scrollDistance + scrollAmount); - } - - /** Handles click events on the pagination arrows. */ - _handlePaginatorClick(direction: ScrollDirection) { - this._stopInterval(); - this._scrollHeader(direction); - } - - /** - * Moves the tab list such that the desired tab label (marked by index) is moved into view. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - _scrollToLabel(labelIndex: number) { - const selectedLabel = this._labelWrappers ? this._labelWrappers.toArray()[labelIndex] : null; - - if (!selectedLabel) { return; } - - // The view length is the visible width of the tab labels. - const viewLength = this._tabListContainer.nativeElement.offsetWidth; - - let labelBeforePos: number, labelAfterPos: number; - if (this._getLayoutDirection() == 'ltr') { - labelBeforePos = selectedLabel.getOffsetLeft(); - labelAfterPos = labelBeforePos + selectedLabel.getOffsetWidth(); - } else { - labelAfterPos = this._tabList.nativeElement.offsetWidth - selectedLabel.getOffsetLeft(); - labelBeforePos = labelAfterPos - selectedLabel.getOffsetWidth(); - } - - const beforeVisiblePos = this.scrollDistance; - const afterVisiblePos = this.scrollDistance + viewLength; - - if (labelBeforePos < beforeVisiblePos) { - // Scroll header to move label to the before direction - this.scrollDistance -= beforeVisiblePos - labelBeforePos + EXAGGERATED_OVERSCROLL; - } else if (labelAfterPos > afterVisiblePos) { - // Scroll header to move label to the after direction - this.scrollDistance += labelAfterPos - afterVisiblePos + EXAGGERATED_OVERSCROLL; - } - } - - /** - * Evaluate whether the pagination controls should be displayed. If the scroll width of the - * tab list is wider than the size of the header container, then the pagination controls should - * be shown. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - _checkPaginationEnabled() { - const isEnabled = - this._tabList.nativeElement.scrollWidth > this._elementRef.nativeElement.offsetWidth; - - if (!isEnabled) { - this.scrollDistance = 0; - } - - if (isEnabled !== this._showPaginationControls) { - this._changeDetectorRef.markForCheck(); - } - - this._showPaginationControls = isEnabled; - } - - /** - * Evaluate whether the before and after controls should be enabled or disabled. - * If the header is at the beginning of the list (scroll distance is equal to 0) then disable the - * before button. If the header is at the end of the list (scroll distance is equal to the - * maximum distance we can scroll), then disable the after button. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - _checkScrollingControls() { - // Check if the pagination arrows should be activated. - this._disableScrollBefore = this.scrollDistance == 0; - this._disableScrollAfter = this.scrollDistance == this._getMaxScrollDistance(); - this._changeDetectorRef.markForCheck(); - } - - /** - * Determines what is the maximum length in pixels that can be set for the scroll distance. This - * is equal to the difference in width between the tab list container and tab header container. - * - * This is an expensive call that forces a layout reflow to compute box and scroll metrics and - * should be called sparingly. - */ - _getMaxScrollDistance(): number { - const lengthOfTabList = this._tabList.nativeElement.scrollWidth; - const viewLength = this._tabListContainer.nativeElement.offsetWidth; - return (lengthOfTabList - viewLength) || 0; - } - - /** Tells the ink-bar to align itself to the current label wrapper */ - _alignInkBarToSelectedTab(): void { - const selectedLabelWrapper = this._labelWrappers && this._labelWrappers.length ? - this._labelWrappers.toArray()[this.selectedIndex].elementRef.nativeElement : - null; - - this._inkBar.alignToElement(selectedLabelWrapper!); - } - - /** Stops the currently-running paginator interval. */ - _stopInterval() { - this._stopScrolling.next(); - } - - /** - * Handles the user pressing down on one of the paginators. - * Starts scrolling the header after a certain amount of time. - * @param direction In which direction the paginator should be scrolled. - */ - _handlePaginatorPress(direction: ScrollDirection) { - // Avoid overlapping timers. - this._stopInterval(); - - // Start a timer after the delay and keep firing based on the interval. - timer(HEADER_SCROLL_DELAY, HEADER_SCROLL_INTERVAL) - // Keep the timer going until something tells it to stop or the component is destroyed. - .pipe(takeUntil(merge(this._stopScrolling, this._destroyed))) - .subscribe(() => { - const {maxScrollDistance, distance} = this._scrollHeader(direction); - - // Stop the timer if we've reached the start or the end. - if (distance === 0 || distance >= maxScrollDistance) { - this._stopInterval(); - } - }); - } - - /** - * Scrolls the header to a given position. - * @param position Position to which to scroll. - * @returns Information on the current scroll distance and the maximum. - */ - private _scrollTo(position: number) { - const maxScrollDistance = this._getMaxScrollDistance(); - this._scrollDistance = Math.max(0, Math.min(maxScrollDistance, position)); - - // Mark that the scroll distance has changed so that after the view is checked, the CSS - // transformation can move the header. - this._scrollDistanceChanged = true; - this._checkScrollingControls(); - - return {maxScrollDistance, distance: this._scrollDistance}; + protected _itemSelected(event: KeyboardEvent) { + event.preventDefault(); } } diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.html b/src/material/tabs/tab-nav-bar/tab-nav-bar.html index d842ff7060db..8d65b9139400 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.html +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.html @@ -1,5 +1,30 @@ -