Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/material/tabs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ ng_module(
"//src/cdk/coercion",
"//src/cdk/keycodes",
"//src/cdk/observers",
"//src/cdk/observers/private",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/cdk/scrolling",
Expand Down Expand Up @@ -95,6 +96,7 @@ ng_test_library(
"//src/cdk/bidi",
"//src/cdk/keycodes",
"//src/cdk/observers",
"//src/cdk/observers/private",
"//src/cdk/portal",
"//src/cdk/scrolling",
"//src/cdk/testing/private",
Expand Down
74 changes: 46 additions & 28 deletions src/material/tabs/paginated-tab-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,45 @@
* found in the LICENSE file at https://angular.io/license
*/

import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
import {SharedResizeObserver} from '@angular/cdk/observers/private';
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {
ChangeDetectorRef,
ElementRef,
NgZone,
Optional,
QueryList,
EventEmitter,
ANIMATION_MODULE_TYPE,
AfterContentChecked,
AfterContentInit,
AfterViewInit,
OnDestroy,
ChangeDetectorRef,
Directive,
ElementRef,
EventEmitter,
Inject,
Injector,
Input,
NgZone,
OnDestroy,
Optional,
Output,
QueryList,
afterNextRender,
booleanAttribute,
inject,
numberAttribute,
Output,
ANIMATION_MODULE_TYPE,
} from '@angular/core';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
import {ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
import {
merge,
of as observableOf,
Subject,
EMPTY,
Observer,
Observable,
timer,
Observer,
Subject,
fromEvent,
merge,
of as observableOf,
timer,
} from 'rxjs';
import {take, switchMap, startWith, skip, takeUntil, filter} from 'rxjs/operators';
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
import {debounceTime, filter, skip, startWith, switchMap, takeUntil} from 'rxjs/operators';

/** Config used to bind passive event listeners */
const passiveEventListenerOptions = normalizePassiveListenerOptions({
Expand Down Expand Up @@ -153,6 +157,10 @@ export abstract class MatPaginatedTabHeader
/** Event emitted when a label is focused. */
@Output() readonly indexFocused: EventEmitter<number> = new EventEmitter<number>();

private _sharedResizeObserver = inject(SharedResizeObserver);

private _injector = inject(Injector);

constructor(
protected _elementRef: ElementRef<HTMLElement>,
protected _changeDetectorRef: ChangeDetectorRef,
Expand Down Expand Up @@ -192,7 +200,18 @@ export abstract class MatPaginatedTabHeader

ngAfterContentInit() {
const dirChange = this._dir ? this._dir.change : observableOf('ltr');
const resize = this._viewportRuler.change(150);
// We need to debounce resize events because the alignment logic is expensive.
// If someone animates the width of tabs, we don't want to realign on every animation frame.
// Once we haven't seen any more resize events in the last 32ms (~2 animaion frames) we can
// re-align.
const resize = this._sharedResizeObserver
.observe(this._elementRef.nativeElement)
.pipe(debounceTime(32), takeUntil(this._destroyed));
// Note: We do not actually need to watch these events for proper functioning of the tabs,
// the resize events above should capture any viewport resize that we care about. However,
// removing this is fairly breaking for screenshot tests, so we're leaving it here for now.
const viewportResize = this._viewportRuler.change(150).pipe(takeUntil(this._destroyed));

const realign = () => {
this.updatePagination();
this._alignInkBarToSelectedTab();
Expand All @@ -207,15 +226,14 @@ export abstract class MatPaginatedTabHeader

this._keyManager.updateActiveItem(this._selectedIndex);

// 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.
// Note that we use `onStable` instead of `requestAnimationFrame`, because the latter
// can hold up tests that are in a background tab.
this._ngZone.onStable.pipe(take(1)).subscribe(realign);
// Note: We do not need to realign after the first render for proper functioning of the tabs
// the resize events above should fire when we first start observing the element. However,
// removing this is fairly breaking for screenshot tests, so we're leaving it here for now.
afterNextRender(realign, {injector: this._injector});

// On dir change or window resize, realign the ink bar and update the orientation of
// On dir change or resize, realign the ink bar and update the orientation of
// the key manager if the direction has changed.
merge(dirChange, resize, this._items.changes, this._itemsResized())
merge(dirChange, viewportResize, resize, this._items.changes, this._itemsResized())
.pipe(takeUntil(this._destroyed))
.subscribe(() => {
// We need to defer this to give the browser some time to recalculate
Expand Down
44 changes: 24 additions & 20 deletions src/material/tabs/tab-header.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,37 @@
import {Dir, Direction} from '@angular/cdk/bidi';
import {END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
import {MutationObserverFactory, ObserversModule} from '@angular/cdk/observers';
import {SharedResizeObserver} from '@angular/cdk/observers/private';
import {PortalModule} from '@angular/cdk/portal';
import {ScrollingModule, ViewportRuler} from '@angular/cdk/scrolling';
import {
dispatchFakeEvent,
dispatchKeyboardEvent,
createKeyboardEvent,
dispatchEvent,
createMouseEvent,
dispatchEvent,
dispatchFakeEvent,
dispatchKeyboardEvent,
} from '@angular/cdk/testing/private';
import {CommonModule} from '@angular/common';
import {Component, ViewChild} from '@angular/core';
import {
waitForAsync,
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
TestBed,
flushMicrotasks,
tick,
waitForAsync,
} from '@angular/core/testing';
import {MatRippleModule} from '@angular/material/core';
import {By} from '@angular/platform-browser';
import {Subject} from 'rxjs';
import {MatTabHeader} from './tab-header';
import {MatTabLabelWrapper} from './tab-label-wrapper';
import {ObserversModule, MutationObserverFactory} from '@angular/cdk/observers';

describe('MDC-based MatTabHeader', () => {
let fixture: ComponentFixture<SimpleTabHeaderApp>;
let appComponent: SimpleTabHeaderApp;
let resizeEvents: Subject<ResizeObserverEntry[]>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
Expand All @@ -45,6 +49,9 @@ describe('MDC-based MatTabHeader', () => {
});

TestBed.compileComponents();

resizeEvents = new Subject();
spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents);
}));

describe('focusing', () => {
Expand Down Expand Up @@ -650,48 +657,45 @@ describe('MDC-based MatTabHeader', () => {
expect(inkBar.alignToElement).toHaveBeenCalled();
}));

it('should re-align the ink bar when the window is resized', fakeAsync(() => {
it('should re-align the ink bar when the header is resized', fakeAsync(() => {
fixture = TestBed.createComponent(SimpleTabHeaderApp);
fixture.detectChanges();

const inkBar = fixture.componentInstance.tabHeader._inkBar;

spyOn(inkBar, 'alignToElement');

dispatchFakeEvent(window, 'resize');
tick(150);
resizeEvents.next([]);
fixture.detectChanges();
tick(32);

expect(inkBar.alignToElement).toHaveBeenCalled();
discardPeriodicTasks();
}));

it('should update arrows when the window is resized', fakeAsync(() => {
it('should update arrows when the header is resized', fakeAsync(() => {
fixture = TestBed.createComponent(SimpleTabHeaderApp);

const header = fixture.componentInstance.tabHeader;

spyOn(header, '_checkPaginationEnabled');

dispatchFakeEvent(window, 'resize');
tick(10);
resizeEvents.next([]);
fixture.detectChanges();
flushMicrotasks();

expect(header._checkPaginationEnabled).toHaveBeenCalled();
discardPeriodicTasks();
}));

it('should update the pagination state if the content of the labels changes', () => {
const mutationCallbacks: Function[] = [];
TestBed.overrideProvider(MutationObserverFactory, {
useValue: {
// Stub out the MutationObserver since the native one is async.
create: function (callback: Function) {
mutationCallbacks.push(callback);
return {observe: () => {}, disconnect: () => {}};
},
spyOn(TestBed.inject(MutationObserverFactory), 'create').and.callFake(
(callback: Function) => {
mutationCallbacks.push(callback);
return {observe: () => {}, disconnect: () => {}} as any;
},
});
);

fixture = TestBed.createComponent(SimpleTabHeaderApp);
fixture.detectChanges();
Expand Down
25 changes: 15 additions & 10 deletions src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import {Direction, Directionality} from '@angular/cdk/bidi';
import {ENTER, SPACE} from '@angular/cdk/keycodes';
import {waitForAsync, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core';
import {By} from '@angular/platform-browser';
import {SharedResizeObserver} from '@angular/cdk/observers/private';
import {
dispatchFakeEvent,
dispatchKeyboardEvent,
dispatchMouseEvent,
} from '@angular/cdk/testing/private';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing';
import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core';
import {By} from '@angular/platform-browser';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {Subject} from 'rxjs';
import {MAT_TABS_CONFIG} from '../index';
import {MatTabsModule} from '../module';
import {MatTabLink, MatTabNav} from './tab-nav-bar';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {MAT_TABS_CONFIG} from '../index';

describe('MDC-based MatTabNavBar', () => {
let dir: Direction = 'ltr';
let dirChange = new Subject();
let globalRippleOptions: RippleGlobalOptions;
let resizeEvents: Subject<ResizeObserverEntry[]>;

beforeEach(waitForAsync(() => {
globalRippleOptions = {};
Expand All @@ -37,6 +39,9 @@ describe('MDC-based MatTabNavBar', () => {
});

TestBed.compileComponents();

resizeEvents = new Subject();
spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents);
}));

describe('basic behavior', () => {
Expand Down Expand Up @@ -174,14 +179,14 @@ describe('MDC-based MatTabNavBar', () => {
expect(spy.calls.any()).toBe(false);
});

it('should re-align the ink bar when the window is resized', fakeAsync(() => {
it('should re-align the ink bar when the nav bar is resized', fakeAsync(() => {
const inkBar = fixture.componentInstance.tabNavBar._inkBar;

spyOn(inkBar, 'alignToElement');

dispatchFakeEvent(window, 'resize');
tick(150);
resizeEvents.next([]);
fixture.detectChanges();
tick(32);

expect(inkBar.alignToElement).toHaveBeenCalled();
}));
Expand Down