Skip to content

Commit b353b75

Browse files
mmalerbamistrykaran91
authored andcommitted
fix(material/form-field): use ResizeObserver for label offset calculation (angular#30702)
* feat(cdk/bidi): add value signal to Directionality * fix(material/form-field): use ResizeObserver for label offset calculation
1 parent ccad18f commit b353b75

File tree

6 files changed

+90
-60
lines changed

6 files changed

+90
-60
lines changed

goldens/cdk/bidi/index.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy {
3131
_rawDir: string;
3232
get value(): Direction;
3333
// (undocumented)
34+
readonly valueSignal: i0.WritableSignal<Direction>;
35+
// (undocumented)
3436
static ɵdir: i0.ɵɵDirectiveDeclaration<Dir, "[dir]", ["dir"], { "dir": { "alias": "dir"; "required": false; }; }, { "change": "dirChange"; }, never, never, true, never>;
3537
// (undocumented)
3638
static ɵfac: i0.ɵɵFactoryDeclaration<Dir, never>;
@@ -48,7 +50,8 @@ export class Directionality implements OnDestroy {
4850
readonly change: EventEmitter<Direction>;
4951
// (undocumented)
5052
ngOnDestroy(): void;
51-
readonly value: Direction;
53+
get value(): Direction;
54+
readonly valueSignal: i0.WritableSignal<Direction>;
5255
// (undocumented)
5356
static ɵfac: i0.ɵɵFactoryDeclaration<Directionality, never>;
5457
// (undocumented)

src/cdk/bidi/dir.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Directive, Output, Input, EventEmitter, AfterContentInit, OnDestroy} from '@angular/core';
9+
import {
10+
AfterContentInit,
11+
Directive,
12+
EventEmitter,
13+
Input,
14+
OnDestroy,
15+
Output,
16+
signal,
17+
} from '@angular/core';
1018

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

@@ -23,9 +31,6 @@ import {Direction, Directionality, _resolveDirectionality} from './directionalit
2331
exportAs: 'dir',
2432
})
2533
export class Dir implements Directionality, AfterContentInit, OnDestroy {
26-
/** Normalized direction that accounts for invalid/unsupported values. */
27-
private _dir: Direction = 'ltr';
28-
2934
/** Whether the `value` has been set to its initial value. */
3035
private _isInitialized: boolean = false;
3136

@@ -38,19 +43,19 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy {
3843
/** @docs-private */
3944
@Input()
4045
get dir(): Direction {
41-
return this._dir;
46+
return this.valueSignal();
4247
}
4348
set dir(value: Direction | 'auto') {
44-
const previousValue = this._dir;
49+
const previousValue = this.valueSignal();
4550

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

52-
if (previousValue !== this._dir && this._isInitialized) {
53-
this.change.emit(this._dir);
57+
if (previousValue !== this.valueSignal() && this._isInitialized) {
58+
this.change.emit(this.valueSignal());
5459
}
5560
}
5661

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

67+
readonly valueSignal = signal<Direction>('ltr');
68+
6269
/** Initialize once default value has been set. */
6370
ngAfterContentInit() {
6471
this._isInitialized = true;

src/cdk/bidi/directionality.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

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

1212
export type Direction = 'ltr' | 'rtl';
@@ -33,7 +33,14 @@ export function _resolveDirectionality(rawValue: string): Direction {
3333
@Injectable({providedIn: 'root'})
3434
export class Directionality implements OnDestroy {
3535
/** The current 'ltr' or 'rtl' value. */
36-
readonly value: Direction = 'ltr';
36+
get value() {
37+
return this.valueSignal();
38+
}
39+
40+
/**
41+
* The current 'ltr' or 'rtl' value.
42+
*/
43+
readonly valueSignal = signal<Direction>('ltr');
3744

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

src/dev-app/dev-app/dev-app-directionality.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@
77
*/
88

99
import {Direction, Directionality} from '@angular/cdk/bidi';
10-
import {EventEmitter, Injectable, OnDestroy} from '@angular/core';
10+
import {EventEmitter, Injectable, OnDestroy, signal} from '@angular/core';
1111

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

1616
get value(): Direction {
17-
return this._value;
17+
return this.valueSignal();
1818
}
1919
set value(value: Direction) {
20-
this._value = value;
20+
this.valueSignal.set(value);
2121
this.change.next(value);
2222
}
23-
private _value: Direction = 'ltr';
23+
24+
valueSignal = signal<Direction>('ltr');
2425

2526
ngOnDestroy() {
2627
this.change.complete();

src/material/datepicker/date-range-input.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {FocusMonitor} from '@angular/cdk/a11y';
2-
import {Directionality} from '@angular/cdk/bidi';
2+
import {Direction, Directionality} from '@angular/cdk/bidi';
33
import {BACKSPACE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
44
import {OverlayContainer} from '@angular/cdk/overlay';
55
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private';
6-
import {Component, Directive, ElementRef, Provider, Type, ViewChild} from '@angular/core';
7-
import {ComponentFixture, TestBed, fakeAsync, flush, inject, tick} from '@angular/core/testing';
6+
import {Component, Directive, ElementRef, Provider, signal, Type, ViewChild} from '@angular/core';
7+
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
88
import {
99
FormControl,
1010
FormGroup,
@@ -15,11 +15,11 @@ import {
1515
Validator,
1616
Validators,
1717
} from '@angular/forms';
18+
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
19+
import {Subscription} from 'rxjs';
1820
import {ErrorStateMatcher, MatNativeDateModule} from '../core';
1921
import {MatFormField, MatFormFieldModule, MatLabel} from '../form-field';
2022
import {MatInputModule} from '../input';
21-
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
22-
import {Subscription} from 'rxjs';
2323
import {MatDateRangeInput} from './date-range-input';
2424
import {MatEndDate, MatStartDate} from './date-range-input-parts';
2525
import {MatDateRangePicker} from './date-range-picker';
@@ -830,7 +830,7 @@ describe('MatDateRangeInput', () => {
830830

831831
it('moves focus between fields with arrow keys when cursor is at edge (RTL)', () => {
832832
class RTL extends Directionality {
833-
override readonly value = 'rtl';
833+
override readonly valueSignal = signal<Direction>('rtl');
834834
}
835835
const fixture = createComponent(StandardRangePicker, [
836836
{

src/material/form-field/form-field.ts

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8+
import {_IdGenerator} from '@angular/cdk/a11y';
89
import {Directionality} from '@angular/cdk/bidi';
910
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
1011
import {Platform} from '@angular/cdk/platform';
@@ -20,23 +21,23 @@ import {
2021
ContentChildren,
2122
ElementRef,
2223
InjectionToken,
23-
Injector,
2424
Input,
2525
NgZone,
2626
OnDestroy,
2727
QueryList,
2828
ViewChild,
2929
ViewEncapsulation,
30-
afterRender,
30+
afterRenderEffect,
3131
computed,
3232
contentChild,
3333
inject,
34+
signal,
35+
viewChild,
3436
} from '@angular/core';
3537
import {AbstractControlDirective, ValidatorFn} from '@angular/forms';
36-
import {_animationsDisabled, ThemePalette} from '../core';
37-
import {_IdGenerator} from '@angular/cdk/a11y';
3838
import {Subject, Subscription, merge} from 'rxjs';
39-
import {map, pairwise, takeUntil, filter, startWith} from 'rxjs/operators';
39+
import {filter, map, pairwise, startWith, takeUntil} from 'rxjs/operators';
40+
import {ThemePalette, _animationsDisabled} from '../core';
4041
import {MAT_ERROR, MatError} from './directives/error';
4142
import {
4243
FLOATING_LABEL_PARENT,
@@ -189,7 +190,6 @@ export class MatFormField
189190
private _platform = inject(Platform);
190191
private _idGenerator = inject(_IdGenerator);
191192
private _ngZone = inject(NgZone);
192-
private _injector = inject(Injector);
193193
private _defaults = inject<MatFormFieldDefaultOptions>(MAT_FORM_FIELD_DEFAULT_OPTIONS, {
194194
optional: true,
195195
});
@@ -203,6 +203,21 @@ export class MatFormField
203203
@ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline | undefined;
204204
@ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple | undefined;
205205

206+
private _iconPrefixContainerSignal = viewChild<ElementRef<HTMLElement>>('iconPrefixContainer');
207+
private _textPrefixContainerSignal = viewChild<ElementRef<HTMLElement>>('textPrefixContainer');
208+
private _iconSuffixContainerSignal = viewChild<ElementRef<HTMLElement>>('iconSuffixContainer');
209+
private _textSuffixContainerSignal = viewChild<ElementRef<HTMLElement>>('textSuffixContainer');
210+
private _prefixSuffixContainers = computed(() => {
211+
return [
212+
this._iconPrefixContainerSignal(),
213+
this._textPrefixContainerSignal(),
214+
this._iconSuffixContainerSignal(),
215+
this._textSuffixContainerSignal(),
216+
]
217+
.map(container => container?.nativeElement)
218+
.filter(e => e !== undefined);
219+
});
220+
206221
@ContentChild(_MatFormFieldControl) _formFieldControl: MatFormFieldControl<any>;
207222
@ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList<MatPrefix>;
208223
@ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList<MatSuffix>;
@@ -250,10 +265,9 @@ export class MatFormField
250265
/** The form field appearance style. */
251266
@Input()
252267
get appearance(): MatFormFieldAppearance {
253-
return this._appearance;
268+
return this._appearanceSignal();
254269
}
255270
set appearance(value: MatFormFieldAppearance) {
256-
const oldValue = this._appearance;
257271
const newAppearance = value || this._defaults?.appearance || DEFAULT_APPEARANCE;
258272
if (typeof ngDevMode === 'undefined' || ngDevMode) {
259273
if (newAppearance !== 'fill' && newAppearance !== 'outline') {
@@ -262,15 +276,9 @@ export class MatFormField
262276
);
263277
}
264278
}
265-
this._appearance = newAppearance;
266-
if (this._appearance === 'outline' && this._appearance !== oldValue) {
267-
// If the appearance has been switched to `outline`, the label offset needs to be updated.
268-
// The update can happen once the view has been re-checked, but not immediately because
269-
// the view has not been updated and the notched-outline floating label is not present.
270-
this._needsOutlineLabelOffsetUpdate = true;
271-
}
279+
this._appearanceSignal.set(newAppearance);
272280
}
273-
private _appearance: MatFormFieldAppearance = DEFAULT_APPEARANCE;
281+
private _appearanceSignal = signal(DEFAULT_APPEARANCE);
274282

275283
/**
276284
* Whether the form field should reserve space for one line of hint/error text (default)
@@ -319,7 +327,6 @@ export class MatFormField
319327
private _destroyed = new Subject<void>();
320328
private _isFocused: boolean | null = null;
321329
private _explicitFormFieldControl: MatFormFieldControl<any>;
322-
private _needsOutlineLabelOffsetUpdate = false;
323330
private _previousControl: MatFormFieldControl<unknown> | null = null;
324331
private _previousControlValidatorFn: ValidatorFn | null = null;
325332
private _stateChanges: Subscription | undefined;
@@ -341,6 +348,8 @@ export class MatFormField
341348
this.color = defaults.color;
342349
}
343350
}
351+
352+
this._syncOutlineLabelOffset();
344353
}
345354

346355
ngAfterViewInit() {
@@ -366,7 +375,6 @@ export class MatFormField
366375
this._assertFormFieldControl();
367376
this._initializeSubscript();
368377
this._initializePrefixAndSuffix();
369-
this._initializeOutlineLabelOffsetSubscriptions();
370378
}
371379

372380
ngAfterContentChecked() {
@@ -399,6 +407,7 @@ export class MatFormField
399407
}
400408

401409
ngOnDestroy() {
410+
this._outlineLabelOffsetResizeObserver?.disconnect();
402411
this._stateChanges?.unsubscribe();
403412
this._valueChanges?.unsubscribe();
404413
this._describedByChanges?.unsubscribe();
@@ -546,34 +555,37 @@ export class MatFormField
546555
);
547556
}
548557

558+
private _outlineLabelOffsetResizeObserver: ResizeObserver | null = null;
559+
549560
/**
550561
* The floating label in the docked state needs to account for prefixes. The horizontal offset
551562
* is calculated whenever the appearance changes to `outline`, the prefixes change, or when the
552563
* form field is added to the DOM. This method sets up all subscriptions which are needed to
553564
* trigger the label offset update.
554565
*/
555-
private _initializeOutlineLabelOffsetSubscriptions() {
566+
private _syncOutlineLabelOffset() {
556567
// Whenever the prefix changes, schedule an update of the label offset.
557-
// TODO(mmalerba): Use ResizeObserver to better support dynamically changing prefix content.
558-
this._prefixChildren.changes.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));
559-
560568
// TODO(mmalerba): Split this into separate `afterRender` calls using the `EarlyRead` and
561569
// `Write` phases.
562-
afterRender(
563-
() => {
564-
if (this._needsOutlineLabelOffsetUpdate) {
565-
this._needsOutlineLabelOffsetUpdate = false;
566-
this._updateOutlineLabelOffset();
570+
afterRenderEffect(() => {
571+
if (this._appearanceSignal() === 'outline') {
572+
this._updateOutlineLabelOffset();
573+
if (!globalThis.ResizeObserver) {
574+
return;
567575
}
568-
},
569-
{
570-
injector: this._injector,
571-
},
572-
);
573576

574-
this._dir.change
575-
.pipe(takeUntil(this._destroyed))
576-
.subscribe(() => (this._needsOutlineLabelOffsetUpdate = true));
577+
// Setup a resize observer to monitor changes to the size of the prefix / suffix and
578+
// readjust the label offset.
579+
this._outlineLabelOffsetResizeObserver ||= new globalThis.ResizeObserver(() =>
580+
this._updateOutlineLabelOffset(),
581+
);
582+
for (const el of this._prefixSuffixContainers()) {
583+
this._outlineLabelOffsetResizeObserver.observe(el, {box: 'border-box'});
584+
}
585+
} else {
586+
this._outlineLabelOffsetResizeObserver?.disconnect();
587+
}
588+
});
577589
}
578590

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

0 commit comments

Comments
 (0)