Skip to content

fix(material/form-field): make notch sizing more reliable #26028

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

Merged
merged 19 commits into from
May 12, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdk/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CDK_ENTRYPOINTS = [
"listbox",
"menu",
"observers",
"observers/private",
"overlay",
"platform",
"portal",
Expand Down
43 changes: 43 additions & 0 deletions src/cdk/observers/private/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
load(
"//tools:defaults.bzl",
"ng_module",
"ng_test_library",
"ng_web_test_suite",
)

package(default_visibility = ["//visibility:public"])

ng_module(
name = "private",
srcs = glob(
["**/*.ts"],
exclude = ["**/*.spec.ts"],
),
deps = [
"//src:dev_mode_types",
"@npm//rxjs",
],
)

ng_test_library(
name = "private_tests_lib",
srcs = glob(
["**/*.spec.ts"],
exclude = ["**/*.e2e.spec.ts"],
),
deps = [
":private",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [
":private_tests_lib",
],
)

filegroup(
name = "source-files",
srcs = glob(["**/*.ts"]),
)
9 changes: 9 additions & 0 deletions src/cdk/observers/private/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

export * from './shared-resize-observer';
108 changes: 108 additions & 0 deletions src/cdk/observers/private/shared-resize-observer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {Component, ElementRef, inject, ViewChild} from '@angular/core';
import {SharedResizeObserver} from './shared-resize-observer';

describe('SharedResizeObserver', () => {
let fixture: ComponentFixture<TestComponent>;
let instance: TestComponent;
let resizeObserver: SharedResizeObserver;
let el1: Element;
let el2: Element;

async function waitForResize() {
fixture.detectChanges();
await new Promise(r => setTimeout(r, 16));
}

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestComponent],
});
fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
instance = fixture.componentInstance;
resizeObserver = instance.resizeObserver;
el1 = instance.el1.nativeElement;
el2 = instance.el2.nativeElement;
});

it('should return the same observable for the same element and same box', () => {
const observable1 = resizeObserver.observe(el1);
const observable2 = resizeObserver.observe(el1);
expect(observable1).toBe(observable2);
});

it('should return different observables for different elements', () => {
const observable1 = resizeObserver.observe(el1);
const observable2 = resizeObserver.observe(el2);
expect(observable1).not.toBe(observable2);
});

it('should return different observables for different boxes', () => {
const observable1 = resizeObserver.observe(el1, {box: 'content-box'});
const observable2 = resizeObserver.observe(el1, {box: 'border-box'});
expect(observable1).not.toBe(observable2);
});

it('should return different observable after all subscriptions unsubscribed', () => {
const observable1 = resizeObserver.observe(el1);
const subscription1 = observable1.subscribe(() => {});
const subscription2 = observable1.subscribe(() => {});
subscription1.unsubscribe();
const observable2 = resizeObserver.observe(el1);
expect(observable1).toBe(observable2);
subscription2.unsubscribe();
const observable3 = resizeObserver.observe(el1);
expect(observable1).not.toBe(observable3);
});

it('should receive an initial size on subscription', waitForAsync(async () => {
const observable = resizeObserver.observe(el1);
const resizeSpy1 = jasmine.createSpy('resize handler 1');
observable.subscribe(resizeSpy1);
await waitForResize();
expect(resizeSpy1).toHaveBeenCalled();
const resizeSpy2 = jasmine.createSpy('resize handler 2');
observable.subscribe(resizeSpy2);
await waitForResize();
expect(resizeSpy2).toHaveBeenCalled();
}));

it('should receive events on resize', waitForAsync(async () => {
const resizeSpy = jasmine.createSpy('resize handler');
resizeObserver.observe(el1).subscribe(resizeSpy);
await waitForResize();
resizeSpy.calls.reset();
instance.el1Width = 1;
await waitForResize();
expect(resizeSpy).toHaveBeenCalled();
}));

it('should not receive events for other elements', waitForAsync(async () => {
const resizeSpy1 = jasmine.createSpy('resize handler 1');
const resizeSpy2 = jasmine.createSpy('resize handler 2');
resizeObserver.observe(el1).subscribe(resizeSpy1);
resizeObserver.observe(el2).subscribe(resizeSpy2);
await waitForResize();
resizeSpy1.calls.reset();
resizeSpy2.calls.reset();
instance.el1Width = 1;
await waitForResize();
expect(resizeSpy1).toHaveBeenCalled();
expect(resizeSpy2).not.toHaveBeenCalled();
}));
});

@Component({
template: `
<div #el1 [style.height.px]="1" [style.width.px]="el1Width"></div>
<div #el2 [style.height.px]="1" [style.width.px]="el2Width"></div>
`,
})
export class TestComponent {
@ViewChild('el1') el1: ElementRef<Element>;
@ViewChild('el2') el2: ElementRef<Element>;
resizeObserver = inject(SharedResizeObserver);
el1Width = 0;
el2Width = 0;
}
138 changes: 138 additions & 0 deletions src/cdk/observers/private/shared-resize-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {inject, Injectable, NgZone, OnDestroy} from '@angular/core';
import {Observable, Subject} from 'rxjs';
import {filter, shareReplay, takeUntil} from 'rxjs/operators';

/**
* Handler that logs "ResizeObserver loop limit exceeded" errors.
* These errors are not shown in the Chrome console, so we log them to ensure developers are aware.
* @param e The error
*/
const loopLimitExceededErrorHandler = (e: unknown) => {
if (e instanceof Error && e.message === 'ResizeObserver loop limit exceeded') {
console.error(
`${e.message}. This could indicate a performance issue with your app. See https://github.com/WICG/resize-observer/blob/master/explainer.md#error-handling`,
);
}
};

/**
* A shared ResizeObserver to be used for a particular box type (content-box, border-box, or
* device-pixel-content-box)
*/
class SingleBoxSharedResizeObserver {
/** Stream that emits when the shared observer is destroyed. */
private _destroyed = new Subject<void>();
/** Stream of all events from the ResizeObserver. */
private _resizeSubject = new Subject<ResizeObserverEntry[]>();
/** ResizeObserver used to observe element resize events. */
private _resizeObserver?: ResizeObserver;
/** A map of elements to streams of their resize events. */
private _elementObservables = new Map<Element, Observable<ResizeObserverEntry[]>>();

constructor(
/** The box type to observe for resizes. */
private _box: ResizeObserverBoxOptions,
) {
if (typeof ResizeObserver !== 'undefined') {
this._resizeObserver = new ResizeObserver(entries => this._resizeSubject.next(entries));
}
}

/**
* Gets a stream of resize events for the given element.
* @param target The element to observe.
* @return The stream of resize events for the element.
*/
observe(target: Element): Observable<ResizeObserverEntry[]> {
if (!this._elementObservables.has(target)) {
this._elementObservables.set(
target,
new Observable<ResizeObserverEntry[]>(observer => {
const subscription = this._resizeSubject.subscribe(observer);
this._resizeObserver?.observe(target, {box: this._box});
return () => {
this._resizeObserver?.unobserve(target);
subscription.unsubscribe();
this._elementObservables.delete(target);
};
}).pipe(
filter(entries => entries.some(entry => entry.target === target)),
// Share a replay of the last event so that subsequent calls to observe the same element
// receive initial sizing info like the first one. Also enable ref counting so the
// element will be automatically unobserved when there are no more subscriptions.
shareReplay({bufferSize: 1, refCount: true}),
takeUntil(this._destroyed),
),
);
}
return this._elementObservables.get(target)!;
}

/** Destroys this instance. */
destroy() {
this._destroyed.next();
this._destroyed.complete();
this._resizeSubject.complete();
this._elementObservables.clear();
}
}

/**
* Allows observing resize events on multiple elements using a shared set of ResizeObserver.
* Sharing a ResizeObserver instance is recommended for better performance (see
* https://github.com/WICG/resize-observer/issues/59).
*
* Rather than share a single `ResizeObserver`, this class creates one `ResizeObserver` per type
* of observed box ('content-box', 'border-box', and 'device-pixel-content-box'). This avoids
* later calls to `observe` with a different box type from influencing the events dispatched to
* earlier calls.
*/
@Injectable({
providedIn: 'root',
})
export class SharedResizeObserver implements OnDestroy {
/** Map of box type to shared resize observer. */
private _observers = new Map<ResizeObserverBoxOptions, SingleBoxSharedResizeObserver>();

/** The Angular zone. */
private _ngZone = inject(NgZone);

constructor() {
if (typeof ResizeObserver !== 'undefined' && (typeof ngDevMode === 'undefined' || ngDevMode)) {
this._ngZone.runOutsideAngular(() => {
window.addEventListener('error', loopLimitExceededErrorHandler);
});
}
}

ngOnDestroy() {
for (const [, observer] of this._observers) {
observer.destroy();
}
this._observers.clear();
if (typeof ResizeObserver !== 'undefined' && (typeof ngDevMode === 'undefined' || ngDevMode)) {
window.removeEventListener('error', loopLimitExceededErrorHandler);
}
}

/**
* Gets a stream of resize events for the given target element and box type.
* @param target The element to observe for resizes.
* @param options Options to pass to the `ResizeObserver`
* @return The stream of resize events for the element.
*/
observe(target: Element, options?: ResizeObserverOptions): Observable<ResizeObserverEntry[]> {
const box = options?.box || 'content-box';
if (!this._observers.has(box)) {
this._observers.set(box, new SingleBoxSharedResizeObserver(box));
}
return this._observers.get(box)!.observe(target);
}
}
16 changes: 16 additions & 0 deletions src/dev-app/input/input-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -850,3 +850,19 @@ <h4>Custom control</h4>
</p>
</mat-card-content>
</mat-card>

<mat-card class="demo-card demo-basic">
<mat-card-content>
<button (click)="showHidden = !showHidden">Show/hide hidden form-field</button>
<button (click)="hiddenLabel = hiddenLabel + '!!'">Add !!</button>
<button (click)="hiddenAppearance = hiddenAppearance === 'fill' ? 'outline' : 'fill'">
Toggle appearance
</button>
<p [style.display]="showHidden ? 'block' : 'none'">
<mat-form-field [appearance]="hiddenAppearance">
<mat-label>{{hiddenLabel}}</mat-label>
<input matInput value="value">
</mat-form-field>
</p>
</mat-card-content>
</mat-card>
3 changes: 3 additions & 0 deletions src/dev-app/input/input-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export class InputDemo {
options: string[] = ['One', 'Two', 'Three'];
showSecondPrefix = false;
showPrefix = true;
showHidden = false;
hiddenLabel = 'Label';
hiddenAppearance: MatFormFieldAppearance = 'outline';

name: string;
errorMessageExample1: string;
Expand Down
2 changes: 1 addition & 1 deletion src/material/form-field/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ng_module(
deps = [
"//src:dev_mode_types",
"//src/cdk/bidi",
"//src/cdk/observers",
"//src/cdk/observers/private",
"//src/cdk/platform",
"//src/material/core",
"@npm//@angular/forms",
Expand Down
Loading