Skip to content

Commit d4e61e2

Browse files
authored
fix(material/table): use ResizeObserver to react to size changes (#28783)
* fix(material/tabs): use ResizeObserver to react to size changes * test: fix tests
1 parent 46db6a6 commit d4e61e2

File tree

4 files changed

+87
-58
lines changed

4 files changed

+87
-58
lines changed

src/material/tabs/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ ng_module(
3030
"//src/cdk/coercion",
3131
"//src/cdk/keycodes",
3232
"//src/cdk/observers",
33+
"//src/cdk/observers/private",
3334
"//src/cdk/platform",
3435
"//src/cdk/portal",
3536
"//src/cdk/scrolling",
@@ -95,6 +96,7 @@ ng_test_library(
9596
"//src/cdk/bidi",
9697
"//src/cdk/keycodes",
9798
"//src/cdk/observers",
99+
"//src/cdk/observers/private",
98100
"//src/cdk/portal",
99101
"//src/cdk/scrolling",
100102
"//src/cdk/testing/private",

src/material/tabs/paginated-tab-header.ts

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,41 +6,45 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
10+
import {Direction, Directionality} from '@angular/cdk/bidi';
11+
import {ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
12+
import {SharedResizeObserver} from '@angular/cdk/observers/private';
13+
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
14+
import {ViewportRuler} from '@angular/cdk/scrolling';
915
import {
10-
ChangeDetectorRef,
11-
ElementRef,
12-
NgZone,
13-
Optional,
14-
QueryList,
15-
EventEmitter,
16+
ANIMATION_MODULE_TYPE,
1617
AfterContentChecked,
1718
AfterContentInit,
1819
AfterViewInit,
19-
OnDestroy,
20+
ChangeDetectorRef,
2021
Directive,
22+
ElementRef,
23+
EventEmitter,
2124
Inject,
25+
Injector,
2226
Input,
27+
NgZone,
28+
OnDestroy,
29+
Optional,
30+
Output,
31+
QueryList,
32+
afterNextRender,
2333
booleanAttribute,
34+
inject,
2435
numberAttribute,
25-
Output,
26-
ANIMATION_MODULE_TYPE,
2736
} from '@angular/core';
28-
import {Direction, Directionality} from '@angular/cdk/bidi';
29-
import {ViewportRuler} from '@angular/cdk/scrolling';
30-
import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y';
31-
import {ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
3237
import {
33-
merge,
34-
of as observableOf,
35-
Subject,
3638
EMPTY,
37-
Observer,
3839
Observable,
39-
timer,
40+
Observer,
41+
Subject,
4042
fromEvent,
43+
merge,
44+
of as observableOf,
45+
timer,
4146
} from 'rxjs';
42-
import {take, switchMap, startWith, skip, takeUntil, filter} from 'rxjs/operators';
43-
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
47+
import {debounceTime, filter, skip, startWith, switchMap, takeUntil} from 'rxjs/operators';
4448

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

160+
private _sharedResizeObserver = inject(SharedResizeObserver);
161+
162+
private _injector = inject(Injector);
163+
156164
constructor(
157165
protected _elementRef: ElementRef<HTMLElement>,
158166
protected _changeDetectorRef: ChangeDetectorRef,
@@ -192,7 +200,18 @@ export abstract class MatPaginatedTabHeader
192200

193201
ngAfterContentInit() {
194202
const dirChange = this._dir ? this._dir.change : observableOf('ltr');
195-
const resize = this._viewportRuler.change(150);
203+
// We need to debounce resize events because the alignment logic is expensive.
204+
// If someone animates the width of tabs, we don't want to realign on every animation frame.
205+
// Once we haven't seen any more resize events in the last 32ms (~2 animaion frames) we can
206+
// re-align.
207+
const resize = this._sharedResizeObserver
208+
.observe(this._elementRef.nativeElement)
209+
.pipe(debounceTime(32), takeUntil(this._destroyed));
210+
// Note: We do not actually need to watch these events for proper functioning of the tabs,
211+
// the resize events above should capture any viewport resize that we care about. However,
212+
// removing this is fairly breaking for screenshot tests, so we're leaving it here for now.
213+
const viewportResize = this._viewportRuler.change(150).pipe(takeUntil(this._destroyed));
214+
196215
const realign = () => {
197216
this.updatePagination();
198217
this._alignInkBarToSelectedTab();
@@ -207,15 +226,14 @@ export abstract class MatPaginatedTabHeader
207226

208227
this._keyManager.updateActiveItem(this._selectedIndex);
209228

210-
// Defer the first call in order to allow for slower browsers to lay out the elements.
211-
// This helps in cases where the user lands directly on a page with paginated tabs.
212-
// Note that we use `onStable` instead of `requestAnimationFrame`, because the latter
213-
// can hold up tests that are in a background tab.
214-
this._ngZone.onStable.pipe(take(1)).subscribe(realign);
229+
// Note: We do not need to realign after the first render for proper functioning of the tabs
230+
// the resize events above should fire when we first start observing the element. However,
231+
// removing this is fairly breaking for screenshot tests, so we're leaving it here for now.
232+
afterNextRender(realign, {injector: this._injector});
215233

216-
// On dir change or window resize, realign the ink bar and update the orientation of
234+
// On dir change or resize, realign the ink bar and update the orientation of
217235
// the key manager if the direction has changed.
218-
merge(dirChange, resize, this._items.changes, this._itemsResized())
236+
merge(dirChange, viewportResize, resize, this._items.changes, this._itemsResized())
219237
.pipe(takeUntil(this._destroyed))
220238
.subscribe(() => {
221239
// We need to defer this to give the browser some time to recalculate

src/material/tabs/tab-header.spec.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,37 @@
11
import {Dir, Direction} from '@angular/cdk/bidi';
22
import {END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
3+
import {MutationObserverFactory, ObserversModule} from '@angular/cdk/observers';
4+
import {SharedResizeObserver} from '@angular/cdk/observers/private';
35
import {PortalModule} from '@angular/cdk/portal';
46
import {ScrollingModule, ViewportRuler} from '@angular/cdk/scrolling';
57
import {
6-
dispatchFakeEvent,
7-
dispatchKeyboardEvent,
88
createKeyboardEvent,
9-
dispatchEvent,
109
createMouseEvent,
10+
dispatchEvent,
11+
dispatchFakeEvent,
12+
dispatchKeyboardEvent,
1113
} from '@angular/cdk/testing/private';
1214
import {CommonModule} from '@angular/common';
1315
import {Component, ViewChild} from '@angular/core';
1416
import {
15-
waitForAsync,
1617
ComponentFixture,
18+
TestBed,
1719
discardPeriodicTasks,
1820
fakeAsync,
19-
TestBed,
21+
flushMicrotasks,
2022
tick,
23+
waitForAsync,
2124
} from '@angular/core/testing';
2225
import {MatRippleModule} from '@angular/material/core';
2326
import {By} from '@angular/platform-browser';
27+
import {Subject} from 'rxjs';
2428
import {MatTabHeader} from './tab-header';
2529
import {MatTabLabelWrapper} from './tab-label-wrapper';
26-
import {ObserversModule, MutationObserverFactory} from '@angular/cdk/observers';
2730

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

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

4751
TestBed.compileComponents();
52+
53+
resizeEvents = new Subject();
54+
spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents);
4855
}));
4956

5057
describe('focusing', () => {
@@ -650,48 +657,45 @@ describe('MDC-based MatTabHeader', () => {
650657
expect(inkBar.alignToElement).toHaveBeenCalled();
651658
}));
652659

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

657664
const inkBar = fixture.componentInstance.tabHeader._inkBar;
658665

659666
spyOn(inkBar, 'alignToElement');
660667

661-
dispatchFakeEvent(window, 'resize');
662-
tick(150);
668+
resizeEvents.next([]);
663669
fixture.detectChanges();
670+
tick(32);
664671

665672
expect(inkBar.alignToElement).toHaveBeenCalled();
666673
discardPeriodicTasks();
667674
}));
668675

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

672679
const header = fixture.componentInstance.tabHeader;
673680

674681
spyOn(header, '_checkPaginationEnabled');
675682

676-
dispatchFakeEvent(window, 'resize');
677-
tick(10);
683+
resizeEvents.next([]);
678684
fixture.detectChanges();
685+
flushMicrotasks();
679686

680687
expect(header._checkPaginationEnabled).toHaveBeenCalled();
681688
discardPeriodicTasks();
682689
}));
683690

684691
it('should update the pagination state if the content of the labels changes', () => {
685692
const mutationCallbacks: Function[] = [];
686-
TestBed.overrideProvider(MutationObserverFactory, {
687-
useValue: {
688-
// Stub out the MutationObserver since the native one is async.
689-
create: function (callback: Function) {
690-
mutationCallbacks.push(callback);
691-
return {observe: () => {}, disconnect: () => {}};
692-
},
693+
spyOn(TestBed.inject(MutationObserverFactory), 'create').and.callFake(
694+
(callback: Function) => {
695+
mutationCallbacks.push(callback);
696+
return {observe: () => {}, disconnect: () => {}} as any;
693697
},
694-
});
698+
);
695699

696700
fixture = TestBed.createComponent(SimpleTabHeaderApp);
697701
fixture.detectChanges();

src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
1+
import {Direction, Directionality} from '@angular/cdk/bidi';
12
import {ENTER, SPACE} from '@angular/cdk/keycodes';
2-
import {waitForAsync, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
3-
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
4-
import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core';
5-
import {By} from '@angular/platform-browser';
3+
import {SharedResizeObserver} from '@angular/cdk/observers/private';
64
import {
75
dispatchFakeEvent,
86
dispatchKeyboardEvent,
97
dispatchMouseEvent,
108
} from '@angular/cdk/testing/private';
11-
import {Direction, Directionality} from '@angular/cdk/bidi';
9+
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
10+
import {ComponentFixture, TestBed, fakeAsync, tick, waitForAsync} from '@angular/core/testing';
11+
import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core';
12+
import {By} from '@angular/platform-browser';
13+
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
1214
import {Subject} from 'rxjs';
15+
import {MAT_TABS_CONFIG} from '../index';
1316
import {MatTabsModule} from '../module';
1417
import {MatTabLink, MatTabNav} from './tab-nav-bar';
15-
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
16-
import {MAT_TABS_CONFIG} from '../index';
1718

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

2325
beforeEach(waitForAsync(() => {
2426
globalRippleOptions = {};
@@ -37,6 +39,9 @@ describe('MDC-based MatTabNavBar', () => {
3739
});
3840

3941
TestBed.compileComponents();
42+
43+
resizeEvents = new Subject();
44+
spyOn(TestBed.inject(SharedResizeObserver), 'observe').and.returnValue(resizeEvents);
4045
}));
4146

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

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

180185
spyOn(inkBar, 'alignToElement');
181186

182-
dispatchFakeEvent(window, 'resize');
183-
tick(150);
187+
resizeEvents.next([]);
184188
fixture.detectChanges();
189+
tick(32);
185190

186191
expect(inkBar.alignToElement).toHaveBeenCalled();
187192
}));

0 commit comments

Comments
 (0)