Skip to content

fix(material/form-field): use ResizeObserver for label offset calculation #30702

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 2 commits into from
Mar 27, 2025
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
5 changes: 4 additions & 1 deletion goldens/cdk/bidi/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy {
_rawDir: string;
get value(): Direction;
// (undocumented)
readonly valueSignal: i0.WritableSignal<Direction>;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<Dir, "[dir]", ["dir"], { "dir": { "alias": "dir"; "required": false; }; }, { "change": "dirChange"; }, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<Dir, never>;
Expand All @@ -48,7 +50,8 @@ export class Directionality implements OnDestroy {
readonly change: EventEmitter<Direction>;
// (undocumented)
ngOnDestroy(): void;
readonly value: Direction;
get value(): Direction;
readonly valueSignal: i0.WritableSignal<Direction>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<Directionality, never>;
// (undocumented)
Expand Down
25 changes: 16 additions & 9 deletions src/cdk/bidi/dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Directive, Output, Input, EventEmitter, AfterContentInit, OnDestroy} from '@angular/core';
import {
AfterContentInit,
Directive,
EventEmitter,
Input,
OnDestroy,
Output,
signal,
} from '@angular/core';

import {Direction, Directionality, _resolveDirectionality} from './directionality';

Expand All @@ -23,9 +31,6 @@ import {Direction, Directionality, _resolveDirectionality} from './directionalit
exportAs: 'dir',
})
export class Dir implements Directionality, AfterContentInit, OnDestroy {
/** Normalized direction that accounts for invalid/unsupported values. */
private _dir: Direction = 'ltr';

/** Whether the `value` has been set to its initial value. */
private _isInitialized: boolean = false;

Expand All @@ -38,19 +43,19 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy {
/** @docs-private */
@Input()
get dir(): Direction {
return this._dir;
return this.valueSignal();
}
set dir(value: Direction | 'auto') {
const previousValue = this._dir;
const previousValue = this.valueSignal();

// Note: `_resolveDirectionality` resolves the language based on the browser's language,
// whereas the browser does it based on the content of the element. Since doing so based
// on the content can be expensive, for now we're doing the simpler matching.
this._dir = _resolveDirectionality(value);
this.valueSignal.set(_resolveDirectionality(value));
this._rawDir = value;

if (previousValue !== this._dir && this._isInitialized) {
this.change.emit(this._dir);
if (previousValue !== this.valueSignal() && this._isInitialized) {
this.change.emit(this.valueSignal());
}
}

Expand All @@ -59,6 +64,8 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy {
return this.dir;
}

readonly valueSignal = signal<Direction>('ltr');

/** Initialize once default value has been set. */
ngAfterContentInit() {
this._isInitialized = true;
Expand Down
13 changes: 10 additions & 3 deletions src/cdk/bidi/directionality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {EventEmitter, Injectable, OnDestroy, inject} from '@angular/core';
import {EventEmitter, Injectable, OnDestroy, inject, signal} from '@angular/core';
import {DIR_DOCUMENT} from './dir-document-token';

export type Direction = 'ltr' | 'rtl';
Expand All @@ -33,7 +33,14 @@ export function _resolveDirectionality(rawValue: string): Direction {
@Injectable({providedIn: 'root'})
export class Directionality implements OnDestroy {
/** The current 'ltr' or 'rtl' value. */
readonly value: Direction = 'ltr';
get value() {
return this.valueSignal();
}

/**
* The current 'ltr' or 'rtl' value.
*/
readonly valueSignal = signal<Direction>('ltr');

/** Stream that emits whenever the 'ltr' / 'rtl' state changes. */
readonly change = new EventEmitter<Direction>();
Expand All @@ -46,7 +53,7 @@ export class Directionality implements OnDestroy {
if (_document) {
const bodyDir = _document.body ? _document.body.dir : null;
const htmlDir = _document.documentElement ? _document.documentElement.dir : null;
this.value = _resolveDirectionality(bodyDir || htmlDir || 'ltr');
this.valueSignal.set(_resolveDirectionality(bodyDir || htmlDir || 'ltr'));
}
}

Expand Down
9 changes: 5 additions & 4 deletions src/dev-app/dev-app/dev-app-directionality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@
*/

import {Direction, Directionality} from '@angular/cdk/bidi';
import {EventEmitter, Injectable, OnDestroy} from '@angular/core';
import {EventEmitter, Injectable, OnDestroy, signal} from '@angular/core';

@Injectable()
export class DevAppDirectionality implements Directionality, OnDestroy {
readonly change = new EventEmitter<Direction>();

get value(): Direction {
return this._value;
return this.valueSignal();
}
set value(value: Direction) {
this._value = value;
this.valueSignal.set(value);
this.change.next(value);
}
private _value: Direction = 'ltr';

valueSignal = signal<Direction>('ltr');

ngOnDestroy() {
this.change.complete();
Expand Down
12 changes: 6 additions & 6 deletions src/material/datepicker/date-range-input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {FocusMonitor} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {OverlayContainer} from '@angular/cdk/overlay';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private';
import {Component, Directive, ElementRef, Provider, Type, ViewChild} from '@angular/core';
import {ComponentFixture, TestBed, fakeAsync, flush, inject, tick} from '@angular/core/testing';
import {Component, Directive, ElementRef, Provider, signal, Type, ViewChild} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
import {
FormControl,
FormGroup,
Expand All @@ -15,11 +15,11 @@ import {
Validator,
Validators,
} from '@angular/forms';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Subscription} from 'rxjs';
import {ErrorStateMatcher, MatNativeDateModule} from '../core';
import {MatFormField, MatFormFieldModule, MatLabel} from '../form-field';
import {MatInputModule} from '../input';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {Subscription} from 'rxjs';
import {MatDateRangeInput} from './date-range-input';
import {MatEndDate, MatStartDate} from './date-range-input-parts';
import {MatDateRangePicker} from './date-range-picker';
Expand Down Expand Up @@ -830,7 +830,7 @@ describe('MatDateRangeInput', () => {

it('moves focus between fields with arrow keys when cursor is at edge (RTL)', () => {
class RTL extends Directionality {
override readonly value = 'rtl';
override readonly valueSignal = signal<Direction>('rtl');
}
const fixture = createComponent(StandardRangePicker, [
{
Expand Down
86 changes: 49 additions & 37 deletions src/material/form-field/form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {_IdGenerator} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {Platform} from '@angular/cdk/platform';
Expand All @@ -20,23 +21,23 @@ import {
ContentChildren,
ElementRef,
InjectionToken,
Injector,
Input,
NgZone,
OnDestroy,
QueryList,
ViewChild,
ViewEncapsulation,
afterRender,
afterRenderEffect,
computed,
contentChild,
inject,
signal,
viewChild,
} from '@angular/core';
import {AbstractControlDirective, ValidatorFn} from '@angular/forms';
import {_animationsDisabled, ThemePalette} from '../core';
import {_IdGenerator} from '@angular/cdk/a11y';
import {Subject, Subscription, merge} from 'rxjs';
import {map, pairwise, takeUntil, filter, startWith} from 'rxjs/operators';
import {filter, map, pairwise, startWith, takeUntil} from 'rxjs/operators';
import {ThemePalette, _animationsDisabled} from '../core';
import {MAT_ERROR, MatError} from './directives/error';
import {
FLOATING_LABEL_PARENT,
Expand Down Expand Up @@ -189,7 +190,6 @@ export class MatFormField
private _platform = inject(Platform);
private _idGenerator = inject(_IdGenerator);
private _ngZone = inject(NgZone);
private _injector = inject(Injector);
private _defaults = inject<MatFormFieldDefaultOptions>(MAT_FORM_FIELD_DEFAULT_OPTIONS, {
optional: true,
});
Expand All @@ -203,6 +203,21 @@ export class MatFormField
@ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline | undefined;
@ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple | undefined;

private _iconPrefixContainerSignal = viewChild<ElementRef<HTMLElement>>('iconPrefixContainer');
private _textPrefixContainerSignal = viewChild<ElementRef<HTMLElement>>('textPrefixContainer');
private _iconSuffixContainerSignal = viewChild<ElementRef<HTMLElement>>('iconSuffixContainer');
private _textSuffixContainerSignal = viewChild<ElementRef<HTMLElement>>('textSuffixContainer');
private _prefixSuffixContainers = computed(() => {
return [
this._iconPrefixContainerSignal(),
this._textPrefixContainerSignal(),
this._iconSuffixContainerSignal(),
this._textSuffixContainerSignal(),
]
.map(container => container?.nativeElement)
.filter(e => e !== undefined);
});

@ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl<any>;
@ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList<MatPrefix>;
@ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList<MatSuffix>;
Expand Down Expand Up @@ -250,10 +265,9 @@ export class MatFormField
/** The form field appearance style. */
@Input()
get appearance(): MatFormFieldAppearance {
return this._appearance;
return this._appearanceSignal();
}
set appearance(value: MatFormFieldAppearance) {
const oldValue = this._appearance;
const newAppearance = value || this._defaults?.appearance || DEFAULT_APPEARANCE;
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (newAppearance !== 'fill' && newAppearance !== 'outline') {
Expand All @@ -262,15 +276,9 @@ export class MatFormField
);
}
}
this._appearance = newAppearance;
if (this._appearance === 'outline' && this._appearance !== oldValue) {
// If the appearance has been switched to `outline`, the label offset needs to be updated.
// The update can happen once the view has been re-checked, but not immediately because
// the view has not been updated and the notched-outline floating label is not present.
this._needsOutlineLabelOffsetUpdate = true;
}
this._appearanceSignal.set(newAppearance);
}
private _appearance: MatFormFieldAppearance = DEFAULT_APPEARANCE;
private _appearanceSignal = signal(DEFAULT_APPEARANCE);

/**
* Whether the form field should reserve space for one line of hint/error text (default)
Expand Down Expand Up @@ -319,7 +327,6 @@ export class MatFormField
private _destroyed = new Subject<void>();
private _isFocused: boolean | null = null;
private _explicitFormFieldControl: MatFormFieldControl<any>;
private _needsOutlineLabelOffsetUpdate = false;
private _previousControl: MatFormFieldControl<unknown> | null = null;
private _previousControlValidatorFn: ValidatorFn | null = null;
private _stateChanges: Subscription | undefined;
Expand All @@ -341,6 +348,8 @@ export class MatFormField
this.color = defaults.color;
}
}

this._syncOutlineLabelOffset();
}

ngAfterViewInit() {
Expand All @@ -366,7 +375,6 @@ export class MatFormField
this._assertFormFieldControl();
this._initializeSubscript();
this._initializePrefixAndSuffix();
this._initializeOutlineLabelOffsetSubscriptions();
}

ngAfterContentChecked() {
Expand Down Expand Up @@ -399,6 +407,7 @@ export class MatFormField
}

ngOnDestroy() {
this._outlineLabelOffsetResizeObserver?.disconnect();
this._stateChanges?.unsubscribe();
this._valueChanges?.unsubscribe();
this._describedByChanges?.unsubscribe();
Expand Down Expand Up @@ -546,34 +555,37 @@ export class MatFormField
);
}

private _outlineLabelOffsetResizeObserver: ResizeObserver | null = null;

/**
* The floating label in the docked state needs to account for prefixes. The horizontal offset
* is calculated whenever the appearance changes to `outline`, the prefixes change, or when the
* form field is added to the DOM. This method sets up all subscriptions which are needed to
* trigger the label offset update.
*/
private _initializeOutlineLabelOffsetSubscriptions() {
private _syncOutlineLabelOffset() {
// Whenever the prefix changes, schedule an update of the label offset.
// TODO(mmalerba): Use ResizeObserver to better support dynamically changing prefix content.
this._prefixChildren.changes.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));

// TODO(mmalerba): Split this into separate `afterRender` calls using the `EarlyRead` and
// `Write` phases.
afterRender(
() => {
if (this._needsOutlineLabelOffsetUpdate) {
this._needsOutlineLabelOffsetUpdate = false;
this._updateOutlineLabelOffset();
afterRenderEffect(() => {
if (this._appearanceSignal() === 'outline') {
this._updateOutlineLabelOffset();
if (!globalThis.ResizeObserver) {
return;
}
},
{
injector: this._injector,
},
);

this._dir.change
.pipe(takeUntil(this._destroyed))
.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));
// Setup a resize observer to monitor changes to the size of the prefix / suffix and
// readjust the label offset.
this._outlineLabelOffsetResizeObserver ||= new globalThis.ResizeObserver(() =>
this._updateOutlineLabelOffset(),
);
for (const el of this._prefixSuffixContainers()) {
this._outlineLabelOffsetResizeObserver.observe(el, {box: 'border-box'});
}
} else {
this._outlineLabelOffsetResizeObserver?.disconnect();
}
});
}

/** Whether the floating label should always float or not. */
Expand Down Expand Up @@ -719,6 +731,7 @@ export class MatFormField
* incorporate the horizontal offset into their default text-field styles.
*/
private _updateOutlineLabelOffset() {
const dir = this._dir.valueSignal();
if (!this._hasOutline() || !this._floatingLabel) {
return;
}
Expand All @@ -732,7 +745,6 @@ export class MatFormField
// If the form field is not attached to the DOM yet (e.g. in a tab), we defer
// the label offset update until the zone stabilizes.
if (!this._isAttachedToDom()) {
this._needsOutlineLabelOffsetUpdate = true;
return;
}
const iconPrefixContainer = this._iconPrefixContainer?.nativeElement;
Expand All @@ -745,7 +757,7 @@ export class MatFormField
const textSuffixContainerWidth = textSuffixContainer?.getBoundingClientRect().width ?? 0;
// If the directionality is RTL, the x-axis transform needs to be inverted. This
// is because `transformX` does not change based on the page directionality.
const negate = this._dir.value === 'rtl' ? '-1' : '1';
const negate = dir === 'rtl' ? '-1' : '1';
const prefixWidth = `${iconPrefixContainerWidth + textPrefixContainerWidth}px`;
const labelOffset = `var(--mat-mdc-form-field-label-offset-x, 0px)`;
const labelHorizontalOffset = `calc(${negate} * (${prefixWidth} + ${labelOffset}))`;
Expand Down
Loading