diff --git a/goldens/cdk/bidi/index.api.md b/goldens/cdk/bidi/index.api.md index 6245020b9454..5ca0f0661766 100644 --- a/goldens/cdk/bidi/index.api.md +++ b/goldens/cdk/bidi/index.api.md @@ -31,6 +31,8 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy { _rawDir: string; get value(): Direction; // (undocumented) + readonly valueSignal: i0.WritableSignal; + // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; @@ -48,7 +50,8 @@ export class Directionality implements OnDestroy { readonly change: EventEmitter; // (undocumented) ngOnDestroy(): void; - readonly value: Direction; + get value(): Direction; + readonly valueSignal: i0.WritableSignal; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) diff --git a/src/cdk/bidi/dir.ts b/src/cdk/bidi/dir.ts index f602a6ac8119..20c1874718f3 100644 --- a/src/cdk/bidi/dir.ts +++ b/src/cdk/bidi/dir.ts @@ -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'; @@ -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; @@ -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()); } } @@ -59,6 +64,8 @@ export class Dir implements Directionality, AfterContentInit, OnDestroy { return this.dir; } + readonly valueSignal = signal('ltr'); + /** Initialize once default value has been set. */ ngAfterContentInit() { this._isInitialized = true; diff --git a/src/cdk/bidi/directionality.ts b/src/cdk/bidi/directionality.ts index c5795d31f0bd..4c74b0734320 100644 --- a/src/cdk/bidi/directionality.ts +++ b/src/cdk/bidi/directionality.ts @@ -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'; @@ -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('ltr'); /** Stream that emits whenever the 'ltr' / 'rtl' state changes. */ readonly change = new EventEmitter(); @@ -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')); } } diff --git a/src/dev-app/dev-app/dev-app-directionality.ts b/src/dev-app/dev-app/dev-app-directionality.ts index 5e9323c45b69..64b833d93455 100644 --- a/src/dev-app/dev-app/dev-app-directionality.ts +++ b/src/dev-app/dev-app/dev-app-directionality.ts @@ -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(); 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('ltr'); ngOnDestroy() { this.change.complete(); diff --git a/src/material/datepicker/date-range-input.spec.ts b/src/material/datepicker/date-range-input.spec.ts index 6fb59091ec42..326f0203400a 100644 --- a/src/material/datepicker/date-range-input.spec.ts +++ b/src/material/datepicker/date-range-input.spec.ts @@ -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, @@ -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'; @@ -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('rtl'); } const fixture = createComponent(StandardRangePicker, [ { diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 3cb238a35f07..bf553f9be85a 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -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'; @@ -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, @@ -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(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true, }); @@ -203,6 +203,21 @@ export class MatFormField @ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline | undefined; @ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple | undefined; + private _iconPrefixContainerSignal = viewChild>('iconPrefixContainer'); + private _textPrefixContainerSignal = viewChild>('textPrefixContainer'); + private _iconSuffixContainerSignal = viewChild>('iconSuffixContainer'); + private _textSuffixContainerSignal = viewChild>('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; @ContentChildren(MAT_PREFIX, {descendants: true}) _prefixChildren: QueryList; @ContentChildren(MAT_SUFFIX, {descendants: true}) _suffixChildren: QueryList; @@ -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') { @@ -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) @@ -319,7 +327,6 @@ export class MatFormField private _destroyed = new Subject(); private _isFocused: boolean | null = null; private _explicitFormFieldControl: MatFormFieldControl; - private _needsOutlineLabelOffsetUpdate = false; private _previousControl: MatFormFieldControl | null = null; private _previousControlValidatorFn: ValidatorFn | null = null; private _stateChanges: Subscription | undefined; @@ -341,6 +348,8 @@ export class MatFormField this.color = defaults.color; } } + + this._syncOutlineLabelOffset(); } ngAfterViewInit() { @@ -366,7 +375,6 @@ export class MatFormField this._assertFormFieldControl(); this._initializeSubscript(); this._initializePrefixAndSuffix(); - this._initializeOutlineLabelOffsetSubscriptions(); } ngAfterContentChecked() { @@ -399,6 +407,7 @@ export class MatFormField } ngOnDestroy() { + this._outlineLabelOffsetResizeObserver?.disconnect(); this._stateChanges?.unsubscribe(); this._valueChanges?.unsubscribe(); this._describedByChanges?.unsubscribe(); @@ -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. */ @@ -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; } @@ -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; @@ -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}))`;