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 @@
-->