Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(material/table): use ResizeObserver to react to size changes #28783

Merged
merged 2 commits into from Mar 30, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/material/tabs/BUILD.bazel
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
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
@@ -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
@@ -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