sass-references/angular-material/material/tabs/tab-group.ts

562 lines
20 KiB
TypeScript

/**
* @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.dev/license
*/
import {
AfterContentChecked,
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChild,
ViewEncapsulation,
booleanAttribute,
inject,
numberAttribute,
ANIMATION_MODULE_TYPE,
} from '@angular/core';
import {MAT_TAB_GROUP, MatTab} from './tab';
import {MatTabHeader} from './tab-header';
import {ThemePalette, MatRipple} from '@angular/material/core';
import {merge, Subscription} from 'rxjs';
import {MAT_TABS_CONFIG, MatTabsConfig} from './tab-config';
import {startWith} from 'rxjs/operators';
import {_IdGenerator, CdkMonitorFocus, FocusOrigin} from '@angular/cdk/a11y';
import {MatTabBody} from './tab-body';
import {CdkPortalOutlet} from '@angular/cdk/portal';
import {MatTabLabelWrapper} from './tab-label-wrapper';
import {Platform} from '@angular/cdk/platform';
/** @docs-private */
export interface MatTabGroupBaseHeader {
_alignInkBarToSelectedTab(): void;
updatePagination(): void;
focusIndex: number;
}
/** Possible positions for the tab header. */
export type MatTabHeaderPosition = 'above' | 'below';
/** Boolean constant that determines whether the tab group supports the `backgroundColor` input */
const ENABLE_BACKGROUND_INPUT = true;
/**
* Material design tab-group component. Supports basic tab pairs (label + content) and includes
* animated ink-bar, keyboard navigation, and screen reader.
* See: https://material.io/design/components/tabs.html
*/
@Component({
selector: 'mat-tab-group',
exportAs: 'matTabGroup',
templateUrl: 'tab-group.html',
styleUrl: 'tab-group.css',
encapsulation: ViewEncapsulation.None,
// tslint:disable-next-line:validate-decorators
changeDetection: ChangeDetectionStrategy.Default,
providers: [
{
provide: MAT_TAB_GROUP,
useExisting: MatTabGroup,
},
],
host: {
'class': 'mat-mdc-tab-group',
'[class]': '"mat-" + (color || "primary")',
'[class.mat-mdc-tab-group-dynamic-height]': 'dynamicHeight',
'[class.mat-mdc-tab-group-inverted-header]': 'headerPosition === "below"',
'[class.mat-mdc-tab-group-stretch-tabs]': 'stretchTabs',
'[attr.mat-align-tabs]': 'alignTabs',
'[style.--mat-tab-animation-duration]': 'animationDuration',
},
imports: [
MatTabHeader,
MatTabLabelWrapper,
CdkMonitorFocus,
MatRipple,
CdkPortalOutlet,
MatTabBody,
],
})
export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDestroy {
readonly _elementRef = inject(ElementRef);
private _changeDetectorRef = inject(ChangeDetectorRef);
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});
/**
* All tabs inside the tab group. This includes tabs that belong to groups that are nested
* inside the current one. We filter out only the tabs that belong to this group in `_tabs`.
*/
@ContentChildren(MatTab, {descendants: true}) _allTabs: QueryList<MatTab>;
@ViewChild('tabBodyWrapper') _tabBodyWrapper: ElementRef;
@ViewChild('tabHeader') _tabHeader: MatTabHeader;
/** All of the tabs that belong to the group. */
_tabs: QueryList<MatTab> = new QueryList<MatTab>();
/** The tab index that should be selected after the content has been checked. */
private _indexToSelect: number | null = 0;
/** Index of the tab that was focused last. */
private _lastFocusedTabIndex: number | null = null;
/** Snapshot of the height of the tab body wrapper before another tab is activated. */
private _tabBodyWrapperHeight: number = 0;
/** Subscription to tabs being added/removed. */
private _tabsSubscription = Subscription.EMPTY;
/** Subscription to changes in the tab labels. */
private _tabLabelSubscription = Subscription.EMPTY;
/**
* Theme color of the tab group. This API is supported in M2 themes only, it
* has no effect in M3 themes.
*
* For information on applying color variants in M3, see
* https://material.angular.io/guide/theming#using-component-color-variants.
*/
@Input()
color: ThemePalette;
/** Whether the ink bar should fit its width to the size of the tab label content. */
@Input({transform: booleanAttribute})
get fitInkBarToContent(): boolean {
return this._fitInkBarToContent;
}
set fitInkBarToContent(value: boolean) {
this._fitInkBarToContent = value;
this._changeDetectorRef.markForCheck();
}
private _fitInkBarToContent = false;
/** Whether tabs should be stretched to fill the header. */
@Input({alias: 'mat-stretch-tabs', transform: booleanAttribute})
stretchTabs: boolean = true;
/** Alignment for tabs label. */
@Input({alias: 'mat-align-tabs'})
alignTabs: string | null = null;
/** Whether the tab group should grow to the size of the active tab. */
@Input({transform: booleanAttribute})
dynamicHeight: boolean = false;
/** The index of the active tab. */
@Input({transform: numberAttribute})
get selectedIndex(): number | null {
return this._selectedIndex;
}
set selectedIndex(value: number) {
this._indexToSelect = isNaN(value) ? null : value;
}
private _selectedIndex: number | null = null;
/** Position of the tab header. */
@Input() headerPosition: MatTabHeaderPosition = 'above';
/** Duration for the tab animation. Will be normalized to milliseconds if no units are set. */
@Input()
get animationDuration(): string {
return this._animationDuration;
}
set animationDuration(value: string | number) {
const stringValue = value + '';
this._animationDuration = /^\d+$/.test(stringValue) ? value + 'ms' : stringValue;
}
private _animationDuration: string;
/**
* `tabindex` to be set on the inner element that wraps the tab content. Can be used for improved
* accessibility when the tab does not have focusable elements or if it has scrollable content.
* The `tabindex` will be removed automatically for inactive tabs.
* Read more at https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-2/tabs.html
*/
@Input({transform: numberAttribute})
get contentTabIndex(): number | null {
return this._contentTabIndex;
}
set contentTabIndex(value: number) {
this._contentTabIndex = isNaN(value) ? null : value;
}
private _contentTabIndex: number | null;
/**
* Whether pagination should be disabled. This can be used to avoid unnecessary
* layout recalculations if it's known that pagination won't be required.
*/
@Input({transform: booleanAttribute})
disablePagination: boolean = false;
/** Whether ripples in the tab group are disabled. */
@Input({transform: booleanAttribute})
disableRipple: boolean = false;
/**
* By default tabs remove their content from the DOM while it's off-screen.
* Setting this to `true` will keep it in the DOM which will prevent elements
* like iframes and videos from reloading next time it comes back into the view.
*/
@Input({transform: booleanAttribute})
preserveContent: boolean = false;
/**
* Theme color of the background of the tab group. This API is supported in M2 themes only, it
* has no effect in M3 themes.
*
* For information on applying color variants in M3, see
* https://material.angular.io/guide/theming#using-component-color-variants.
*
* @deprecated The background color should be customized through Sass theming APIs.
* @breaking-change 20.0.0 Remove this input
*/
@Input()
get backgroundColor(): ThemePalette {
return this._backgroundColor;
}
set backgroundColor(value: ThemePalette) {
if (!ENABLE_BACKGROUND_INPUT) {
throw new Error(`mat-tab-group background color must be set through the Sass theming API`);
}
const classList: DOMTokenList = this._elementRef.nativeElement.classList;
classList.remove('mat-tabs-with-background', `mat-background-${this.backgroundColor}`);
if (value) {
classList.add('mat-tabs-with-background', `mat-background-${value}`);
}
this._backgroundColor = value;
}
private _backgroundColor: ThemePalette;
/** Aria label of the inner `tablist` of the group. */
@Input('aria-label') ariaLabel: string;
/** Sets the `aria-labelledby` of the inner `tablist` of the group. */
@Input('aria-labelledby') ariaLabelledby: string;
/** Output to enable support for two-way binding on `[(selectedIndex)]` */
@Output() readonly selectedIndexChange: EventEmitter<number> = new EventEmitter<number>();
/** Event emitted when focus has changed within a tab group. */
@Output() readonly focusChange: EventEmitter<MatTabChangeEvent> =
new EventEmitter<MatTabChangeEvent>();
/** Event emitted when the body animation has completed */
@Output() readonly animationDone: EventEmitter<void> = new EventEmitter<void>();
/** Event emitted when the tab selection has changed. */
@Output() readonly selectedTabChange: EventEmitter<MatTabChangeEvent> =
new EventEmitter<MatTabChangeEvent>(true);
private _groupId: string;
/** Whether the tab group is rendered on the server. */
protected _isServer: boolean = !inject(Platform).isBrowser;
constructor(...args: unknown[]);
constructor() {
const defaultConfig = inject<MatTabsConfig>(MAT_TABS_CONFIG, {optional: true});
this._groupId = inject(_IdGenerator).getId('mat-tab-group-');
this.animationDuration =
defaultConfig && defaultConfig.animationDuration ? defaultConfig.animationDuration : '500ms';
this.disablePagination =
defaultConfig && defaultConfig.disablePagination != null
? defaultConfig.disablePagination
: false;
this.dynamicHeight =
defaultConfig && defaultConfig.dynamicHeight != null ? defaultConfig.dynamicHeight : false;
if (defaultConfig?.contentTabIndex != null) {
this.contentTabIndex = defaultConfig.contentTabIndex;
}
this.preserveContent = !!defaultConfig?.preserveContent;
this.fitInkBarToContent =
defaultConfig && defaultConfig.fitInkBarToContent != null
? defaultConfig.fitInkBarToContent
: false;
this.stretchTabs =
defaultConfig && defaultConfig.stretchTabs != null ? defaultConfig.stretchTabs : true;
this.alignTabs =
defaultConfig && defaultConfig.alignTabs != null ? defaultConfig.alignTabs : null;
}
/**
* After the content is checked, this component knows what tabs have been defined
* and what the selected index should be. This is where we can know exactly what position
* each tab should be in according to the new selected index, and additionally we know how
* a new selected tab should transition in (from the left or right).
*/
ngAfterContentChecked() {
// Don't clamp the `indexToSelect` immediately in the setter because it can happen that
// the amount of tabs changes before the actual change detection runs.
const indexToSelect = (this._indexToSelect = this._clampTabIndex(this._indexToSelect));
// If there is a change in selected index, emit a change event. Should not trigger if
// the selected index has not yet been initialized.
if (this._selectedIndex != indexToSelect) {
const isFirstRun = this._selectedIndex == null;
if (!isFirstRun) {
this.selectedTabChange.emit(this._createChangeEvent(indexToSelect));
// Preserve the height so page doesn't scroll up during tab change.
// Fixes https://stackblitz.com/edit/mat-tabs-scroll-page-top-on-tab-change
const wrapper = this._tabBodyWrapper.nativeElement;
wrapper.style.minHeight = wrapper.clientHeight + 'px';
}
// Changing these values after change detection has run
// since the checked content may contain references to them.
Promise.resolve().then(() => {
this._tabs.forEach((tab, index) => (tab.isActive = index === indexToSelect));
if (!isFirstRun) {
this.selectedIndexChange.emit(indexToSelect);
// Clear the min-height, this was needed during tab change to avoid
// unnecessary scrolling.
this._tabBodyWrapper.nativeElement.style.minHeight = '';
}
});
}
// Setup the position for each tab and optionally setup an origin on the next selected tab.
this._tabs.forEach((tab: MatTab, index: number) => {
tab.position = index - indexToSelect;
// If there is already a selected tab, then set up an origin for the next selected tab
// if it doesn't have one already.
if (this._selectedIndex != null && tab.position == 0 && !tab.origin) {
tab.origin = indexToSelect - this._selectedIndex;
}
});
if (this._selectedIndex !== indexToSelect) {
this._selectedIndex = indexToSelect;
this._lastFocusedTabIndex = null;
this._changeDetectorRef.markForCheck();
}
}
ngAfterContentInit() {
this._subscribeToAllTabChanges();
this._subscribeToTabLabels();
// Subscribe to changes in the amount of tabs, in order to be
// able to re-render the content as new tabs are added or removed.
this._tabsSubscription = this._tabs.changes.subscribe(() => {
const indexToSelect = this._clampTabIndex(this._indexToSelect);
// Maintain the previously-selected tab if a new tab is added or removed and there is no
// explicit change that selects a different tab.
if (indexToSelect === this._selectedIndex) {
const tabs = this._tabs.toArray();
let selectedTab: MatTab | undefined;
for (let i = 0; i < tabs.length; i++) {
if (tabs[i].isActive) {
// Assign both to the `_indexToSelect` and `_selectedIndex` so we don't fire a changed
// event, otherwise the consumer may end up in an infinite loop in some edge cases like
// adding a tab within the `selectedIndexChange` event.
this._indexToSelect = this._selectedIndex = i;
this._lastFocusedTabIndex = null;
selectedTab = tabs[i];
break;
}
}
// If we haven't found an active tab and a tab exists at the selected index, it means
// that the active tab was swapped out. Since this won't be picked up by the rendering
// loop in `ngAfterContentChecked`, we need to sync it up manually.
if (!selectedTab && tabs[indexToSelect]) {
Promise.resolve().then(() => {
tabs[indexToSelect].isActive = true;
this.selectedTabChange.emit(this._createChangeEvent(indexToSelect));
});
}
}
this._changeDetectorRef.markForCheck();
});
}
/** Listens to changes in all of the tabs. */
private _subscribeToAllTabChanges() {
// Since we use a query with `descendants: true` to pick up the tabs, we may end up catching
// some that are inside of nested tab groups. We filter them out manually by checking that
// the closest group to the tab is the current one.
this._allTabs.changes.pipe(startWith(this._allTabs)).subscribe((tabs: QueryList<MatTab>) => {
this._tabs.reset(
tabs.filter(tab => {
return tab._closestTabGroup === this || !tab._closestTabGroup;
}),
);
this._tabs.notifyOnChanges();
});
}
ngOnDestroy() {
this._tabs.destroy();
this._tabsSubscription.unsubscribe();
this._tabLabelSubscription.unsubscribe();
}
/** Re-aligns the ink bar to the selected tab element. */
realignInkBar() {
if (this._tabHeader) {
this._tabHeader._alignInkBarToSelectedTab();
}
}
/**
* Recalculates the tab group's pagination dimensions.
*
* 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() {
if (this._tabHeader) {
this._tabHeader.updatePagination();
}
}
/**
* Sets focus to a particular tab.
* @param index Index of the tab to be focused.
*/
focusTab(index: number) {
const header = this._tabHeader;
if (header) {
header.focusIndex = index;
}
}
_focusChanged(index: number) {
this._lastFocusedTabIndex = index;
this.focusChange.emit(this._createChangeEvent(index));
}
private _createChangeEvent(index: number): MatTabChangeEvent {
const event = new MatTabChangeEvent();
event.index = index;
if (this._tabs && this._tabs.length) {
event.tab = this._tabs.toArray()[index];
}
return event;
}
/**
* Subscribes to changes in the tab labels. This is needed, because the @Input for the label is
* on the MatTab component, whereas the data binding is inside the MatTabGroup. In order for the
* binding to be updated, we need to subscribe to changes in it and trigger change detection
* manually.
*/
private _subscribeToTabLabels() {
if (this._tabLabelSubscription) {
this._tabLabelSubscription.unsubscribe();
}
this._tabLabelSubscription = merge(...this._tabs.map(tab => tab._stateChanges)).subscribe(() =>
this._changeDetectorRef.markForCheck(),
);
}
/** Clamps the given index to the bounds of 0 and the tabs length. */
private _clampTabIndex(index: number | null): number {
// Note the `|| 0`, which ensures that values like NaN can't get through
// and which would otherwise throw the component into an infinite loop
// (since Math.max(NaN, 0) === NaN).
return Math.min(this._tabs.length - 1, Math.max(index || 0, 0));
}
/** Returns a unique id for each tab label element */
_getTabLabelId(i: number): string {
return `${this._groupId}-label-${i}`;
}
/** Returns a unique id for each tab content element */
_getTabContentId(i: number): string {
return `${this._groupId}-content-${i}`;
}
/**
* Sets the height of the body wrapper to the height of the activating tab if dynamic
* height property is true.
*/
_setTabBodyWrapperHeight(tabHeight: number): void {
if (!this.dynamicHeight || !this._tabBodyWrapperHeight) {
return;
}
const wrapper: HTMLElement = this._tabBodyWrapper.nativeElement;
wrapper.style.height = this._tabBodyWrapperHeight + 'px';
// This conditional forces the browser to paint the height so that
// the animation to the new height can have an origin.
if (this._tabBodyWrapper.nativeElement.offsetHeight) {
wrapper.style.height = tabHeight + 'px';
}
}
/** Removes the height of the tab body wrapper. */
_removeTabBodyWrapperHeight(): void {
const wrapper = this._tabBodyWrapper.nativeElement;
this._tabBodyWrapperHeight = wrapper.clientHeight;
wrapper.style.height = '';
this.animationDone.emit();
}
/** Handle click events, setting new selected index if appropriate. */
_handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number) {
tabHeader.focusIndex = index;
if (!tab.disabled) {
this.selectedIndex = index;
}
}
/** Retrieves the tabindex for the tab. */
_getTabIndex(index: number): number {
const targetIndex = this._lastFocusedTabIndex ?? this.selectedIndex;
return index === targetIndex ? 0 : -1;
}
/** Callback for when the focused state of a tab has changed. */
_tabFocusChanged(focusOrigin: FocusOrigin, index: number) {
// Mouse/touch focus happens during the `mousedown`/`touchstart` phase which
// can cause the tab to be moved out from under the pointer, interrupting the
// click sequence (see #21898). We don't need to scroll the tab into view for
// such cases anyway, because it will be done when the tab becomes selected.
if (focusOrigin && focusOrigin !== 'mouse' && focusOrigin !== 'touch') {
this._tabHeader.focusIndex = index;
}
}
}
/** A simple change event emitted on focus or selection changes. */
export class MatTabChangeEvent {
/** Index of the currently-selected tab. */
index: number;
/** Reference to the currently-selected tab. */
tab: MatTab;
}