Skip to content

Commit

Permalink
fix(material/table): use ResizeObserver to react to size changes (#28783
Browse files Browse the repository at this point in the history
)

* fix(material/tabs): use ResizeObserver to react to size changes

* test: fix tests
  • Loading branch information
mmalerba committed Mar 30, 2024
1 parent 46db6a6 commit d4e61e2
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 58 deletions.
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

0 comments on commit d4e61e2

Please sign in to comment.