diff --git a/src/cdk/config.bzl b/src/cdk/config.bzl index ef490d23be14..956010dfe44d 100644 --- a/src/cdk/config.bzl +++ b/src/cdk/config.bzl @@ -13,6 +13,7 @@ CDK_ENTRYPOINTS = [ "listbox", "menu", "observers", + "observers/private", "overlay", "platform", "portal", diff --git a/src/cdk/observers/private/BUILD.bazel b/src/cdk/observers/private/BUILD.bazel new file mode 100644 index 000000000000..0bc731af0f97 --- /dev/null +++ b/src/cdk/observers/private/BUILD.bazel @@ -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"]), +) diff --git a/src/cdk/observers/private/index.ts b/src/cdk/observers/private/index.ts new file mode 100644 index 000000000000..b1cc5ddf5aed --- /dev/null +++ b/src/cdk/observers/private/index.ts @@ -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'; diff --git a/src/cdk/observers/private/shared-resize-observer.spec.ts b/src/cdk/observers/private/shared-resize-observer.spec.ts new file mode 100644 index 000000000000..48b514811571 --- /dev/null +++ b/src/cdk/observers/private/shared-resize-observer.spec.ts @@ -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; + 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: ` +
+
+ `, +}) +export class TestComponent { + @ViewChild('el1') el1: ElementRef; + @ViewChild('el2') el2: ElementRef; + resizeObserver = inject(SharedResizeObserver); + el1Width = 0; + el2Width = 0; +} diff --git a/src/cdk/observers/private/shared-resize-observer.ts b/src/cdk/observers/private/shared-resize-observer.ts new file mode 100644 index 000000000000..5dee6aa17a6f --- /dev/null +++ b/src/cdk/observers/private/shared-resize-observer.ts @@ -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(); + /** Stream of all events from the ResizeObserver. */ + private _resizeSubject = new Subject(); + /** ResizeObserver used to observe element resize events. */ + private _resizeObserver?: ResizeObserver; + /** A map of elements to streams of their resize events. */ + private _elementObservables = new Map>(); + + 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 { + if (!this._elementObservables.has(target)) { + this._elementObservables.set( + target, + new Observable(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(); + + /** 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 { + 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); + } +} diff --git a/src/dev-app/input/input-demo.html b/src/dev-app/input/input-demo.html index 054ad8d4cc96..e2c5e1cbff5f 100644 --- a/src/dev-app/input/input-demo.html +++ b/src/dev-app/input/input-demo.html @@ -850,3 +850,19 @@

Custom control

+ + + + + + +

+ + {{hiddenLabel}} + + +

+
+
diff --git a/src/dev-app/input/input-demo.ts b/src/dev-app/input/input-demo.ts index 50b0f9cc2e0b..3371aeee77b8 100644 --- a/src/dev-app/input/input-demo.ts +++ b/src/dev-app/input/input-demo.ts @@ -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; diff --git a/src/material/form-field/BUILD.bazel b/src/material/form-field/BUILD.bazel index d89be8714f6f..20f74ac032cb 100644 --- a/src/material/form-field/BUILD.bazel +++ b/src/material/form-field/BUILD.bazel @@ -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", diff --git a/src/material/form-field/directives/floating-label.ts b/src/material/form-field/directives/floating-label.ts index b8dfb9ef91ba..dc3a7569ac62 100644 --- a/src/material/form-field/directives/floating-label.ts +++ b/src/material/form-field/directives/floating-label.ts @@ -6,7 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, Input} from '@angular/core'; +import { + Directive, + ElementRef, + inject, + Input, + NgZone, + OnDestroy, + InjectionToken, +} from '@angular/core'; +import {SharedResizeObserver} from '@angular/cdk/observers/private'; +import {Subscription} from 'rxjs'; + +/** An interface that the parent form-field should implement to receive resize events. */ +export interface FloatingLabelParent { + _handleLabelResized(): void; +} + +/** An injion token for the parent form-field. */ +export const FLOATING_LABEL_PARENT = new InjectionToken('FloatingLabelParent'); /** * Internal directive that maintains a MDC floating label. This directive does not @@ -28,12 +46,53 @@ import {Directive, ElementRef, Input} from '@angular/core'; '[class.mdc-floating-label--float-above]': 'floating', }, }) -export class MatFormFieldFloatingLabel { +export class MatFormFieldFloatingLabel implements OnDestroy { /** Whether the label is floating. */ - @Input() floating: boolean = false; + @Input() + get floating() { + return this._floating; + } + set floating(value: boolean) { + this._floating = value; + if (this.monitorResize) { + this._handleResize(); + } + } + private _floating = false; + + /** Whether to monitor for resize events on the floating label. */ + @Input() + get monitorResize() { + return this._monitorResize; + } + set monitorResize(value: boolean) { + this._monitorResize = value; + if (this._monitorResize) { + this._subscribeToResize(); + } else { + this._resizeSubscription.unsubscribe(); + } + } + private _monitorResize = false; + + /** The shared ResizeObserver. */ + private _resizeObserver = inject(SharedResizeObserver); + + /** The Angular zone. */ + private _ngZone = inject(NgZone); + + /** The parent form-field. */ + private _parent = inject(FLOATING_LABEL_PARENT); + + /** The current resize event subscription. */ + private _resizeSubscription = new Subscription(); constructor(private _elementRef: ElementRef) {} + ngOnDestroy() { + this._resizeSubscription.unsubscribe(); + } + /** Gets the width of the label. Used for the outline notch. */ getWidth(): number { return estimateScrollWidth(this._elementRef.nativeElement); @@ -43,6 +102,29 @@ export class MatFormFieldFloatingLabel { get element(): HTMLElement { return this._elementRef.nativeElement; } + + /** Handles resize events from the ResizeObserver. */ + private _handleResize() { + // In the case where the label grows in size, the following sequence of events occurs: + // 1. The label grows by 1px triggering the ResizeObserver + // 2. The notch is expanded to accommodate the entire label + // 3. The label expands to its full width, triggering the ResizeObserver again + // + // This is expected, but If we allow this to all happen within the same macro task it causes an + // error: `ResizeObserver loop limit exceeded`. Therefore we push the notch resize out until + // the next macro task. + setTimeout(() => this._parent._handleLabelResized()); + } + + /** Subscribes to resize events. */ + private _subscribeToResize() { + this._resizeSubscription.unsubscribe(); + this._ngZone.runOutsideAngular(() => { + this._resizeSubscription = this._resizeObserver + .observe(this._elementRef.nativeElement, {box: 'border-box'}) + .subscribe(() => this._handleResize()); + }); + } } /** diff --git a/src/material/form-field/directives/notched-outline.html b/src/material/form-field/directives/notched-outline.html index 76e5276145bb..a7a66160b83a 100644 --- a/src/material/form-field/directives/notched-outline.html +++ b/src/material/form-field/directives/notched-outline.html @@ -1,5 +1,5 @@
-
+
diff --git a/src/material/form-field/directives/notched-outline.ts b/src/material/form-field/directives/notched-outline.ts index 36a4ed45e651..3b81c370e9a1 100644 --- a/src/material/form-field/directives/notched-outline.ts +++ b/src/material/form-field/directives/notched-outline.ts @@ -13,6 +13,7 @@ import { ElementRef, Input, NgZone, + ViewChild, ViewEncapsulation, } from '@angular/core'; @@ -35,12 +36,11 @@ import { encapsulation: ViewEncapsulation.None, }) export class MatFormFieldNotchedOutline implements AfterViewInit { - /** Width of the label (original scale) */ - @Input('matFormFieldNotchedOutlineLabelWidth') labelWidth: number = 0; - /** Whether the notch should be opened. */ @Input('matFormFieldNotchedOutlineOpen') open: boolean = false; + @ViewChild('notch') _notch: ElementRef; + constructor(private _elementRef: ElementRef, private _ngZone: NgZone) {} ngAfterViewInit(): void { @@ -59,17 +59,15 @@ export class MatFormFieldNotchedOutline implements AfterViewInit { } } - _getNotchWidth() { - if (this.open) { + _setNotchWidth(labelWidth: number) { + if (!this.open || !labelWidth) { + this._notch.nativeElement.style.width = ''; + } else { const NOTCH_ELEMENT_PADDING = 8; const NOTCH_ELEMENT_BORDER = 1; - return this.labelWidth > 0 - ? `calc(${this.labelWidth}px * var(--mat-mdc-form-field-floating-label-scale, 0.75) + ${ - NOTCH_ELEMENT_PADDING + NOTCH_ELEMENT_BORDER - }px)` - : '0px'; + this._notch.nativeElement.style.width = `calc(${labelWidth}px * var(--mat-mdc-form-field-floating-label-scale, 0.75) + ${ + NOTCH_ELEMENT_PADDING + NOTCH_ELEMENT_BORDER + }px)`; } - - return null; } } diff --git a/src/material/form-field/form-field.html b/src/material/form-field/form-field.html index fb719f85cbed..ec1576f24a5f 100644 --- a/src/material/form-field/form-field.html +++ b/src/material/form-field/form-field.html @@ -16,9 +16,8 @@ -->