diff --git a/src/material/tabs/BUILD.bazel b/src/material/tabs/BUILD.bazel index b12706c5b63a..884d68647e5a 100644 --- a/src/material/tabs/BUILD.bazel +++ b/src/material/tabs/BUILD.bazel @@ -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", @@ -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", diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts index 7781faf3baa2..cc5363ad6c38 100644 --- a/src/material/tabs/paginated-tab-header.ts +++ b/src/material/tabs/paginated-tab-header.ts @@ -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({ @@ -153,6 +157,10 @@ export abstract class MatPaginatedTabHeader /** Event emitted when a label is focused. */ @Output() readonly indexFocused: EventEmitter = new EventEmitter(); + private _sharedResizeObserver = inject(SharedResizeObserver); + + private _injector = inject(Injector); + constructor( protected _elementRef: ElementRef, protected _changeDetectorRef: ChangeDetectorRef, @@ -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(); @@ -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 diff --git a/src/material/tabs/tab-header.spec.ts b/src/material/tabs/tab-header.spec.ts index 6e254d0b3717..66d741ad5a58 100644 --- a/src/material/tabs/tab-header.spec.ts +++ b/src/material/tabs/tab-header.spec.ts @@ -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; let appComponent: SimpleTabHeaderApp; + let resizeEvents: Subject; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -45,6 +49,9 @@ describe('MDC-based MatTabHeader', () => { }); TestBed.compileComponents(); + + resizeEvents = new Subject(); + spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents); })); describe('focusing', () => { @@ -650,7 +657,7 @@ 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(); @@ -658,24 +665,24 @@ describe('MDC-based MatTabHeader', () => { 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(); @@ -683,15 +690,12 @@ describe('MDC-based MatTabHeader', () => { 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(); diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts index 21a89ff3ef91..e50fdd15f1f2 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -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; beforeEach(waitForAsync(() => { globalRippleOptions = {}; @@ -37,6 +39,9 @@ describe('MDC-based MatTabNavBar', () => { }); TestBed.compileComponents(); + + resizeEvents = new Subject(); + spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents); })); describe('basic behavior', () => { @@ -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(); }));