From 3a364f44fb261912fb749d289e5f40da966bb405 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 7 Oct 2018 14:18:34 +0300 Subject: [PATCH] fix(form-field): outline gap not being calculated when element starts off invisible Fixes the gaps for a `mat-form-field` with the `outline` appearance not being calculated properly if the element starts off as being invisible and then becomes visible later. Fixes #13328. --- src/lib/form-field/form-field.ts | 77 +++++++++++++++++++++++++------- src/lib/input/input.spec.ts | 40 +++++++++++++++++ 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/lib/form-field/form-field.ts b/src/lib/form-field/form-field.ts index 1081f697037f..9a106996d879 100644 --- a/src/lib/form-field/form-field.ts +++ b/src/lib/form-field/form-field.ts @@ -26,6 +26,7 @@ import { QueryList, ViewChild, ViewEncapsulation, + OnDestroy, } from '@angular/core'; import { CanColor, CanColorCtor, @@ -34,8 +35,8 @@ import { MAT_LABEL_GLOBAL_OPTIONS, mixinColor, } from '@angular/material/core'; -import {fromEvent, merge} from 'rxjs'; -import {startWith, take} from 'rxjs/operators'; +import {fromEvent, merge, Subject} from 'rxjs'; +import {startWith, take, takeUntil} from 'rxjs/operators'; import {MatError} from './error'; import {matFormFieldAnimations} from './form-field-animations'; import {MatFormFieldControl} from './form-field-control'; @@ -141,9 +142,18 @@ export const MAT_FORM_FIELD_DEFAULT_OPTIONS = }) export class MatFormField extends _MatFormFieldMixinBase - implements AfterContentInit, AfterContentChecked, AfterViewInit, CanColor { + implements AfterContentInit, AfterContentChecked, AfterViewInit, OnDestroy, CanColor { private _labelOptions: LabelOptions; - private _outlineGapCalculationNeeded = false; + + /** + * Whether the outline gap needs to be calculated + * immediately on the next change detection run. + */ + private _outlineGapCalculationNeededImmediately = false; + + /** Whether the outline gap needs to be calculated next time the zone has stabilized. */ + private _outlineGapCalculationNeededOnStable = false; + private _destroyed = new Subject(); /** The form-field appearance style. */ @Input() @@ -283,7 +293,19 @@ export class MatFormField extends _MatFormFieldMixinBase // Run change detection if the value changes. if (control.ngControl && control.ngControl.valueChanges) { - control.ngControl.valueChanges.subscribe(() => this._changeDetectorRef.markForCheck()); + control.ngControl.valueChanges + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._changeDetectorRef.markForCheck()); + } + + // @breaking-change 7.0.0 Remove this check once _ngZone is required. Also reconsider + // whether the `ngAfterContentChecked` below is still necessary. + if (this._ngZone) { + this._ngZone.onStable.asObservable().pipe(takeUntil(this._destroyed)).subscribe(() => { + if (this._outlineGapCalculationNeededOnStable) { + this.updateOutlineGap(); + } + }); } // Run change detection and update the outline if the suffix or prefix changes. @@ -307,7 +329,7 @@ export class MatFormField extends _MatFormFieldMixinBase ngAfterContentChecked() { this._validateControlChild(); - if (this._outlineGapCalculationNeeded) { + if (this._outlineGapCalculationNeededImmediately) { this.updateOutlineGap(); } } @@ -318,6 +340,11 @@ export class MatFormField extends _MatFormFieldMixinBase this._changeDetectorRef.detectChanges(); } + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + /** Determines whether a class from the NgControl should be forwarded to the host element. */ _shouldForward(prop: keyof NgControl): boolean { const ngControl = this._control ? this._control.ngControl : null; @@ -468,19 +495,33 @@ export class MatFormField extends _MatFormFieldMixinBase // If the element is not present in the DOM, the outline gap will need to be calculated // the next time it is checked and in the DOM. if (!document.documentElement!.contains(this._elementRef.nativeElement)) { - this._outlineGapCalculationNeeded = true; + this._outlineGapCalculationNeededImmediately = true; return; } let startWidth = 0; let gapWidth = 0; - const startEls = this._connectionContainerRef.nativeElement.querySelectorAll( - '.mat-form-field-outline-start'); - const gapEls = this._connectionContainerRef.nativeElement.querySelectorAll( - '.mat-form-field-outline-gap'); + + const container = this._connectionContainerRef.nativeElement; + const startEls = container.querySelectorAll('.mat-form-field-outline-start'); + const gapEls = container.querySelectorAll('.mat-form-field-outline-gap'); + if (this._label && this._label.nativeElement.children.length) { - const containerStart = this._getStartEnd( - this._connectionContainerRef.nativeElement.getBoundingClientRect()); + const containerRect = container.getBoundingClientRect(); + + // If the container's width and height are zero, it means that the element is + // invisible and we can't calculate the outline gap. Mark the element as needing + // to be checked the next time the zone stabilizes. We can't do this immediately + // on the next change detection, because even if the element becomes visible, + // the `ClientRect` won't be reclaculated immediately. We reset the + // `_outlineGapCalculationNeededImmediately` flag some we don't run the checks twice. + if (containerRect.width === 0 && containerRect.height === 0) { + this._outlineGapCalculationNeededOnStable = true; + this._outlineGapCalculationNeededImmediately = false; + return; + } + + const containerStart = this._getStartEnd(containerRect); const labelStart = this._getStartEnd(labelEl.children[0].getBoundingClientRect()); let labelWidth = 0; @@ -498,7 +539,8 @@ export class MatFormField extends _MatFormFieldMixinBase gapEls.item(i).style.width = `${gapWidth}px`; } - this._outlineGapCalculationNeeded = false; + this._outlineGapCalculationNeededOnStable = + this._outlineGapCalculationNeededImmediately = false; } /** Gets the start end of the rect considering the current directionality. */ @@ -506,11 +548,14 @@ export class MatFormField extends _MatFormFieldMixinBase return this._dir && this._dir.value === 'rtl' ? rect.right : rect.left; } - /** Updates the outline gap the new time the zone stabilizes. */ + /** + * Updates the outline gap the new time the zone stabilizes. + * @breaking-change 7.0.0 Remove this method and only set the property once `_ngZone` is required. + */ private _updateOutlineGapOnStable() { // @breaking-change 8.0.0 Remove this check and else block once _ngZone is required. if (this._ngZone) { - this._ngZone.onStable.pipe(take(1)).subscribe(() => this.updateOutlineGap()); + this._outlineGapCalculationNeededOnStable = true; } else { Promise.resolve().then(() => this.updateOutlineGap()); } diff --git a/src/lib/input/input.spec.ts b/src/lib/input/input.spec.ts index b00e3a147c12..7a2fd6a26ef0 100644 --- a/src/lib/input/input.spec.ts +++ b/src/lib/input/input.spec.ts @@ -1341,6 +1341,35 @@ describe('MatInput with appearance', () => { expect(outlineFixture.componentInstance.formField.updateOutlineGap).toHaveBeenCalled(); })); + it('should calculate the outline gaps if the element starts off invisible', fakeAsync(() => { + fixture.destroy(); + TestBed.resetTestingModule(); + + let zone: MockNgZone; + const invisibleFixture = createComponent(MatInputWithOutlineInsideInvisibleElement, [{ + provide: NgZone, + useFactory: () => zone = new MockNgZone() + }]); + + invisibleFixture.detectChanges(); + zone!.simulateZoneExit(); + flush(); + invisibleFixture.detectChanges(); + + const wrapperElement = invisibleFixture.nativeElement; + const formField = wrapperElement.querySelector('.mat-form-field'); + const outlineStart = wrapperElement.querySelector('.mat-form-field-outline-start'); + const outlineGap = wrapperElement.querySelector('.mat-form-field-outline-gap'); + + formField.style.display = ''; + invisibleFixture.detectChanges(); + zone!.simulateZoneExit(); + flush(); + invisibleFixture.detectChanges(); + + expect(parseInt(outlineStart.style.width)).toBeGreaterThan(0); + expect(parseInt(outlineGap.style.width)).toBeGreaterThan(0); + })); }); @@ -1820,6 +1849,17 @@ class MatInputWithAppearanceAndLabel { class MatInputWithoutPlaceholder { } +@Component({ + template: ` + + Label + + + ` +}) +class MatInputWithOutlineInsideInvisibleElement {} + + // Styles to reset padding and border to make measurement comparisons easier. const textareaStyleReset = ` textarea {