Skip to content

Commit cda36e9

Browse files
authored
fix(material/form-field): make notch sizing more reliable (#26028)
* fix(material/form-field): make notch sizing more reliable Swap out the current logic for determining when to resize the outline gap, for logic based on ResizeObserver. The current logic tries to guess when the size may need to change based on observing the content, checking after fonts load, etc. But using ResizeObserver should catch more cases. Also brings the gap calculation logic outside the NgZone to avoid running unnecessary change detections. * test: add a demo for initially hidden form-field * test: fix prerender test theme * fix: add shared resize observer service * fix: observe resize on the floating label * fix: update notch when label resizes * fix: update notch size outside of angular change detection * fix: don't respond to resize on fill form-fields * fix: don't observe resize in ssr * ci: fix ci issues * fix: eliminate loop limit exceeded error * refactor: use observables for SharedResizeObserver * test: add tests for SharedResizeObserver * fix: address feedback * refactor: move resize observer to private cdk entrypoint * fix: fix ssr * fix: address feedback * ci: fix ci * test: fix screenshot tests
1 parent de688da commit cda36e9

File tree

18 files changed

+463
-56
lines changed

18 files changed

+463
-56
lines changed

src/cdk/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CDK_ENTRYPOINTS = [
1313
"listbox",
1414
"menu",
1515
"observers",
16+
"observers/private",
1617
"overlay",
1718
"platform",
1819
"portal",

src/cdk/observers/private/BUILD.bazel

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
load(
2+
"//tools:defaults.bzl",
3+
"ng_module",
4+
"ng_test_library",
5+
"ng_web_test_suite",
6+
)
7+
8+
package(default_visibility = ["//visibility:public"])
9+
10+
ng_module(
11+
name = "private",
12+
srcs = glob(
13+
["**/*.ts"],
14+
exclude = ["**/*.spec.ts"],
15+
),
16+
deps = [
17+
"//src:dev_mode_types",
18+
"@npm//rxjs",
19+
],
20+
)
21+
22+
ng_test_library(
23+
name = "private_tests_lib",
24+
srcs = glob(
25+
["**/*.spec.ts"],
26+
exclude = ["**/*.e2e.spec.ts"],
27+
),
28+
deps = [
29+
":private",
30+
],
31+
)
32+
33+
ng_web_test_suite(
34+
name = "unit_tests",
35+
deps = [
36+
":private_tests_lib",
37+
],
38+
)
39+
40+
filegroup(
41+
name = "source-files",
42+
srcs = glob(["**/*.ts"]),
43+
)

src/cdk/observers/private/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export * from './shared-resize-observer';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
2+
import {Component, ElementRef, inject, ViewChild} from '@angular/core';
3+
import {SharedResizeObserver} from './shared-resize-observer';
4+
5+
describe('SharedResizeObserver', () => {
6+
let fixture: ComponentFixture<TestComponent>;
7+
let instance: TestComponent;
8+
let resizeObserver: SharedResizeObserver;
9+
let el1: Element;
10+
let el2: Element;
11+
12+
async function waitForResize() {
13+
fixture.detectChanges();
14+
await new Promise(r => setTimeout(r, 16));
15+
}
16+
17+
beforeEach(() => {
18+
TestBed.configureTestingModule({
19+
declarations: [TestComponent],
20+
});
21+
fixture = TestBed.createComponent(TestComponent);
22+
fixture.detectChanges();
23+
instance = fixture.componentInstance;
24+
resizeObserver = instance.resizeObserver;
25+
el1 = instance.el1.nativeElement;
26+
el2 = instance.el2.nativeElement;
27+
});
28+
29+
it('should return the same observable for the same element and same box', () => {
30+
const observable1 = resizeObserver.observe(el1);
31+
const observable2 = resizeObserver.observe(el1);
32+
expect(observable1).toBe(observable2);
33+
});
34+
35+
it('should return different observables for different elements', () => {
36+
const observable1 = resizeObserver.observe(el1);
37+
const observable2 = resizeObserver.observe(el2);
38+
expect(observable1).not.toBe(observable2);
39+
});
40+
41+
it('should return different observables for different boxes', () => {
42+
const observable1 = resizeObserver.observe(el1, {box: 'content-box'});
43+
const observable2 = resizeObserver.observe(el1, {box: 'border-box'});
44+
expect(observable1).not.toBe(observable2);
45+
});
46+
47+
it('should return different observable after all subscriptions unsubscribed', () => {
48+
const observable1 = resizeObserver.observe(el1);
49+
const subscription1 = observable1.subscribe(() => {});
50+
const subscription2 = observable1.subscribe(() => {});
51+
subscription1.unsubscribe();
52+
const observable2 = resizeObserver.observe(el1);
53+
expect(observable1).toBe(observable2);
54+
subscription2.unsubscribe();
55+
const observable3 = resizeObserver.observe(el1);
56+
expect(observable1).not.toBe(observable3);
57+
});
58+
59+
it('should receive an initial size on subscription', waitForAsync(async () => {
60+
const observable = resizeObserver.observe(el1);
61+
const resizeSpy1 = jasmine.createSpy('resize handler 1');
62+
observable.subscribe(resizeSpy1);
63+
await waitForResize();
64+
expect(resizeSpy1).toHaveBeenCalled();
65+
const resizeSpy2 = jasmine.createSpy('resize handler 2');
66+
observable.subscribe(resizeSpy2);
67+
await waitForResize();
68+
expect(resizeSpy2).toHaveBeenCalled();
69+
}));
70+
71+
it('should receive events on resize', waitForAsync(async () => {
72+
const resizeSpy = jasmine.createSpy('resize handler');
73+
resizeObserver.observe(el1).subscribe(resizeSpy);
74+
await waitForResize();
75+
resizeSpy.calls.reset();
76+
instance.el1Width = 1;
77+
await waitForResize();
78+
expect(resizeSpy).toHaveBeenCalled();
79+
}));
80+
81+
it('should not receive events for other elements', waitForAsync(async () => {
82+
const resizeSpy1 = jasmine.createSpy('resize handler 1');
83+
const resizeSpy2 = jasmine.createSpy('resize handler 2');
84+
resizeObserver.observe(el1).subscribe(resizeSpy1);
85+
resizeObserver.observe(el2).subscribe(resizeSpy2);
86+
await waitForResize();
87+
resizeSpy1.calls.reset();
88+
resizeSpy2.calls.reset();
89+
instance.el1Width = 1;
90+
await waitForResize();
91+
expect(resizeSpy1).toHaveBeenCalled();
92+
expect(resizeSpy2).not.toHaveBeenCalled();
93+
}));
94+
});
95+
96+
@Component({
97+
template: `
98+
<div #el1 [style.height.px]="1" [style.width.px]="el1Width"></div>
99+
<div #el2 [style.height.px]="1" [style.width.px]="el2Width"></div>
100+
`,
101+
})
102+
export class TestComponent {
103+
@ViewChild('el1') el1: ElementRef<Element>;
104+
@ViewChild('el2') el2: ElementRef<Element>;
105+
resizeObserver = inject(SharedResizeObserver);
106+
el1Width = 0;
107+
el2Width = 0;
108+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {inject, Injectable, NgZone, OnDestroy} from '@angular/core';
9+
import {Observable, Subject} from 'rxjs';
10+
import {filter, shareReplay, takeUntil} from 'rxjs/operators';
11+
12+
/**
13+
* Handler that logs "ResizeObserver loop limit exceeded" errors.
14+
* These errors are not shown in the Chrome console, so we log them to ensure developers are aware.
15+
* @param e The error
16+
*/
17+
const loopLimitExceededErrorHandler = (e: unknown) => {
18+
if (e instanceof Error && e.message === 'ResizeObserver loop limit exceeded') {
19+
console.error(
20+
`${e.message}. This could indicate a performance issue with your app. See https://github.com/WICG/resize-observer/blob/master/explainer.md#error-handling`,
21+
);
22+
}
23+
};
24+
25+
/**
26+
* A shared ResizeObserver to be used for a particular box type (content-box, border-box, or
27+
* device-pixel-content-box)
28+
*/
29+
class SingleBoxSharedResizeObserver {
30+
/** Stream that emits when the shared observer is destroyed. */
31+
private _destroyed = new Subject<void>();
32+
/** Stream of all events from the ResizeObserver. */
33+
private _resizeSubject = new Subject<ResizeObserverEntry[]>();
34+
/** ResizeObserver used to observe element resize events. */
35+
private _resizeObserver?: ResizeObserver;
36+
/** A map of elements to streams of their resize events. */
37+
private _elementObservables = new Map<Element, Observable<ResizeObserverEntry[]>>();
38+
39+
constructor(
40+
/** The box type to observe for resizes. */
41+
private _box: ResizeObserverBoxOptions,
42+
) {
43+
if (typeof ResizeObserver !== 'undefined') {
44+
this._resizeObserver = new ResizeObserver(entries => this._resizeSubject.next(entries));
45+
}
46+
}
47+
48+
/**
49+
* Gets a stream of resize events for the given element.
50+
* @param target The element to observe.
51+
* @return The stream of resize events for the element.
52+
*/
53+
observe(target: Element): Observable<ResizeObserverEntry[]> {
54+
if (!this._elementObservables.has(target)) {
55+
this._elementObservables.set(
56+
target,
57+
new Observable<ResizeObserverEntry[]>(observer => {
58+
const subscription = this._resizeSubject.subscribe(observer);
59+
this._resizeObserver?.observe(target, {box: this._box});
60+
return () => {
61+
this._resizeObserver?.unobserve(target);
62+
subscription.unsubscribe();
63+
this._elementObservables.delete(target);
64+
};
65+
}).pipe(
66+
filter(entries => entries.some(entry => entry.target === target)),
67+
// Share a replay of the last event so that subsequent calls to observe the same element
68+
// receive initial sizing info like the first one. Also enable ref counting so the
69+
// element will be automatically unobserved when there are no more subscriptions.
70+
shareReplay({bufferSize: 1, refCount: true}),
71+
takeUntil(this._destroyed),
72+
),
73+
);
74+
}
75+
return this._elementObservables.get(target)!;
76+
}
77+
78+
/** Destroys this instance. */
79+
destroy() {
80+
this._destroyed.next();
81+
this._destroyed.complete();
82+
this._resizeSubject.complete();
83+
this._elementObservables.clear();
84+
}
85+
}
86+
87+
/**
88+
* Allows observing resize events on multiple elements using a shared set of ResizeObserver.
89+
* Sharing a ResizeObserver instance is recommended for better performance (see
90+
* https://github.com/WICG/resize-observer/issues/59).
91+
*
92+
* Rather than share a single `ResizeObserver`, this class creates one `ResizeObserver` per type
93+
* of observed box ('content-box', 'border-box', and 'device-pixel-content-box'). This avoids
94+
* later calls to `observe` with a different box type from influencing the events dispatched to
95+
* earlier calls.
96+
*/
97+
@Injectable({
98+
providedIn: 'root',
99+
})
100+
export class SharedResizeObserver implements OnDestroy {
101+
/** Map of box type to shared resize observer. */
102+
private _observers = new Map<ResizeObserverBoxOptions, SingleBoxSharedResizeObserver>();
103+
104+
/** The Angular zone. */
105+
private _ngZone = inject(NgZone);
106+
107+
constructor() {
108+
if (typeof ResizeObserver !== 'undefined' && (typeof ngDevMode === 'undefined' || ngDevMode)) {
109+
this._ngZone.runOutsideAngular(() => {
110+
window.addEventListener('error', loopLimitExceededErrorHandler);
111+
});
112+
}
113+
}
114+
115+
ngOnDestroy() {
116+
for (const [, observer] of this._observers) {
117+
observer.destroy();
118+
}
119+
this._observers.clear();
120+
if (typeof ResizeObserver !== 'undefined' && (typeof ngDevMode === 'undefined' || ngDevMode)) {
121+
window.removeEventListener('error', loopLimitExceededErrorHandler);
122+
}
123+
}
124+
125+
/**
126+
* Gets a stream of resize events for the given target element and box type.
127+
* @param target The element to observe for resizes.
128+
* @param options Options to pass to the `ResizeObserver`
129+
* @return The stream of resize events for the element.
130+
*/
131+
observe(target: Element, options?: ResizeObserverOptions): Observable<ResizeObserverEntry[]> {
132+
const box = options?.box || 'content-box';
133+
if (!this._observers.has(box)) {
134+
this._observers.set(box, new SingleBoxSharedResizeObserver(box));
135+
}
136+
return this._observers.get(box)!.observe(target);
137+
}
138+
}

src/dev-app/input/input-demo.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,3 +850,19 @@ <h4>Custom control</h4>
850850
</p>
851851
</mat-card-content>
852852
</mat-card>
853+
854+
<mat-card class="demo-card demo-basic">
855+
<mat-card-content>
856+
<button (click)="showHidden = !showHidden">Show/hide hidden form-field</button>
857+
<button (click)="hiddenLabel = hiddenLabel + '!!'">Add !!</button>
858+
<button (click)="hiddenAppearance = hiddenAppearance === 'fill' ? 'outline' : 'fill'">
859+
Toggle appearance
860+
</button>
861+
<p [style.display]="showHidden ? 'block' : 'none'">
862+
<mat-form-field [appearance]="hiddenAppearance">
863+
<mat-label>{{hiddenLabel}}</mat-label>
864+
<input matInput value="value">
865+
</mat-form-field>
866+
</p>
867+
</mat-card-content>
868+
</mat-card>

src/dev-app/input/input-demo.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ export class InputDemo {
6868
options: string[] = ['One', 'Two', 'Three'];
6969
showSecondPrefix = false;
7070
showPrefix = true;
71+
showHidden = false;
72+
hiddenLabel = 'Label';
73+
hiddenAppearance: MatFormFieldAppearance = 'outline';
7174

7275
name: string;
7376
errorMessageExample1: string;

src/material/form-field/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ng_module(
1818
deps = [
1919
"//src:dev_mode_types",
2020
"//src/cdk/bidi",
21-
"//src/cdk/observers",
21+
"//src/cdk/observers/private",
2222
"//src/cdk/platform",
2323
"//src/material/core",
2424
"@npm//@angular/forms",

0 commit comments

Comments
 (0)