-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
199cfe8
fix(material/form-field): make notch sizing more reliable
mmalerba fd742aa
test: add a demo for initially hidden form-field
mmalerba 7c52196
test: fix prerender test theme
mmalerba 698d454
fix: add shared resize observer service
mmalerba d8092ce
fix: observe resize on the floating label
mmalerba a67bf36
fix: update notch when label resizes
mmalerba 298e266
fix: update notch size outside of angular change detection
mmalerba e673548
fix: don't respond to resize on fill form-fields
mmalerba 6778c57
fix: don't observe resize in ssr
mmalerba e8e542b
ci: fix ci issues
mmalerba 3be8689
fix: eliminate loop limit exceeded error
mmalerba d06d114
refactor: use observables for SharedResizeObserver
mmalerba ebcbb8e
test: add tests for SharedResizeObserver
mmalerba ea2834e
fix: address feedback
mmalerba 3aa9a17
refactor: move resize observer to private cdk entrypoint
mmalerba b03f034
fix: fix ssr
mmalerba b88351a
fix: address feedback
mmalerba ca8c388
ci: fix ci
mmalerba e02f2b8
test: fix screenshot tests
mmalerba File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]), | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
108
src/cdk/observers/private/shared-resize-observer.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
crisbeto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* @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; | ||
crisbeto marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/** 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)); | ||
mmalerba marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
/** | ||
* 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); | ||
mmalerba marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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() { | ||
mmalerba marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.