From 0b94669a5423faea4ef8fd2d390d2e7cfa5e8847 Mon Sep 17 00:00:00 2001 From: Jeremy Mowery Date: Thu, 11 Mar 2021 12:25:41 -0800 Subject: [PATCH] fix(cdk/text-field): autosize text areas using the placeholder Fixes a bug with CdkTextareaAutosize where the textarea would not be autosized when using long placeholders Fixes #22042 Cache the height with the placeholder do calculation better fix comment Stop caching the placeholder because I can't make the caching approach work with view-engine go back to the caching approach Account for the input tests fix lint error --- scripts/check-mdc-tests-config.ts | 2 +- src/cdk/text-field/autosize.spec.ts | 62 ++++++++++++++++++++-- src/cdk/text-field/autosize.ts | 50 ++++++++++++----- src/material/input/input.spec.ts | 6 +-- tools/public_api_guard/cdk/text-field.d.ts | 4 +- 5 files changed, 103 insertions(+), 21 deletions(-) diff --git a/scripts/check-mdc-tests-config.ts b/scripts/check-mdc-tests-config.ts index bccb7185e851..4da723183dcb 100644 --- a/scripts/check-mdc-tests-config.ts +++ b/scripts/check-mdc-tests-config.ts @@ -77,7 +77,7 @@ export const config = { 'should calculate the outline gaps inside the shadow DOM', 'should be legacy appearance if no default options provided', 'should be legacy appearance if empty default options provided', - 'should not calculate wrong content height due to long placeholders', + 'should adjust height due to long placeholders', 'should work in a tab', 'should work in a step' ], diff --git a/src/cdk/text-field/autosize.spec.ts b/src/cdk/text-field/autosize.spec.ts index a2a7463c427c..f80350cdcffb 100644 --- a/src/cdk/text-field/autosize.spec.ts +++ b/src/cdk/text-field/autosize.spec.ts @@ -50,7 +50,7 @@ describe('CdkTextareaAutosize', () => { it('should resize the textarea based on its content', () => { let previousHeight = textarea.clientHeight; - fixture.componentInstance.content = ` + textarea.value = ` Once upon a midnight dreary, while I pondered, weak and weary, Over many a quaint and curious volume of forgotten lore— While I nodded, nearly napping, suddenly there came a tapping, @@ -68,7 +68,7 @@ describe('CdkTextareaAutosize', () => { .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); previousHeight = textarea.clientHeight; - fixture.componentInstance.content += ` + textarea.value += ` Ah, distinctly I remember it was in the bleak December; And each separate dying ember wrought its ghost upon the floor. Eagerly I wished the morrow;—vainly I had sought to borrow @@ -85,6 +85,38 @@ describe('CdkTextareaAutosize', () => { .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); }); + it('should keep the placeholder size if the value is shorter than the placeholder', () => { + fixture = TestBed.createComponent(AutosizeTextAreaWithContent); + + textarea = fixture.nativeElement.querySelector('textarea'); + autosize = fixture.debugElement.query(By.css('textarea'))! + .injector.get(CdkTextareaAutosize); + + fixture.componentInstance.placeholder = ` + Once upon a midnight dreary, while I pondered, weak and weary, + Over many a quaint and curious volume of forgotten lore— + While I nodded, nearly napping, suddenly there came a tapping, + As of some one gently rapping, rapping at my chamber door. + “’Tis some visitor,” I muttered, “tapping at my chamber door— + Only this and nothing more.”`; + + fixture.detectChanges(); + + expect(textarea.clientHeight) + .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); + + let previousHeight = textarea.clientHeight; + + textarea.value = 'a'; + + // Manually call resizeToFitContent instead of faking an `input` event. + fixture.detectChanges(); + autosize.resizeToFitContent(); + + expect(textarea.clientHeight) + .toBe(previousHeight, 'Expected textarea height not to have changed'); + }); + it('should set a min-height based on minRows', () => { expect(textarea.style.minHeight).toBeFalsy(); @@ -161,7 +193,7 @@ describe('CdkTextareaAutosize', () => { }); it('should calculate the proper height based on the specified amount of max rows', () => { - fixture.componentInstance.content = [1, 2, 3, 4, 5, 6, 7, 8].join('\n'); + textarea.value = [1, 2, 3, 4, 5, 6, 7, 8].join('\n'); fixture.detectChanges(); autosize.resizeToFitContent(); @@ -196,6 +228,27 @@ describe('CdkTextareaAutosize', () => { .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); }); + it('should properly resize to placeholder on init', () => { + // Manually create the test component in this test, because in this test the first change + // detection should be triggered after a multiline placeholder is set. + fixture = TestBed.createComponent(AutosizeTextAreaWithContent); + textarea = fixture.nativeElement.querySelector('textarea'); + autosize = fixture.debugElement.query(By.css('textarea'))! + .injector.get(CdkTextareaAutosize); + + fixture.componentInstance.placeholder = ` + Line + Line + Line + Line + Line`; + + fixture.detectChanges(); + + expect(textarea.clientHeight) + .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); + }); + it('should resize when an associated form control value changes', fakeAsync(() => { const fixtureWithForms = TestBed.createComponent(AutosizeTextareaWithNgModel); textarea = fixtureWithForms.nativeElement.querySelector('textarea'); @@ -298,7 +351,7 @@ const textareaStyleReset = ` @Component({ template: ` `, + #autosize="cdkTextareaAutosize" [placeholder]="placeholder">{{content}}`, styles: [textareaStyleReset], }) class AutosizeTextAreaWithContent { @@ -306,6 +359,7 @@ class AutosizeTextAreaWithContent { minRows: number | null = null; maxRows: number | null = null; content: string = ''; + placeholder: string = ''; } @Component({ diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index 74752924f0b6..9e2263893ad6 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -88,8 +88,19 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { } } + @Input() + get placeholder(): string { return this._textareaElement.placeholder; } + set placeholder(value: string) { + this._cachedPlaceholderHeight = undefined; + this._textareaElement.placeholder = value; + this._cacheTextareaPlaceholderHeight(); + } + + /** Cached height of a textarea with a single row. */ private _cachedLineHeight: number; + /** Cached height of a textarea with only the placeholder. */ + private _cachedPlaceholderHeight?: number; /** Used to reference correct document/window */ protected _document?: Document; @@ -195,6 +206,30 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { this._setMaxHeight(); } + private _measureScrollHeight(): number { + // Reset the textarea height to auto in order to shrink back to its default size. + // Also temporarily force overflow:hidden, so scroll bars do not interfere with calculations. + this._textareaElement.classList.add(this._measuringClass); + // The measuring class includes a 2px padding to workaround an issue with Chrome, + // so we account for that extra space here by subtracting 4 (2px top + 2px bottom). + const scrollHeight = this._textareaElement.scrollHeight - 4; + this._textareaElement.classList.remove(this._measuringClass); + + return scrollHeight; + } + + private _cacheTextareaPlaceholderHeight(): void { + if (this._cachedPlaceholderHeight) { + return; + } + + const value = this._textareaElement.value; + + this._textareaElement.value = this._textareaElement.placeholder; + this._cachedPlaceholderHeight = this._measureScrollHeight(); + this._textareaElement.value = value; + } + ngDoCheck() { if (this._platform.isBrowser) { this.resizeToFitContent(); @@ -213,6 +248,7 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { } this._cacheTextareaLineHeight(); + this._cacheTextareaPlaceholderHeight(); // If we haven't determined the line-height yet, we know we're still hidden and there's no point // in checking the height of the textarea. @@ -228,24 +264,14 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { return; } - const placeholderText = textarea.placeholder; - - // Reset the textarea height to auto in order to shrink back to its default size. - // Also temporarily force overflow:hidden, so scroll bars do not interfere with calculations. - // Long placeholders that are wider than the textarea width may lead to a bigger scrollHeight - // value. To ensure that the scrollHeight is not bigger than the content, the placeholders - // need to be removed temporarily. - textarea.classList.add(this._measuringClass); - textarea.placeholder = ''; + const scrollHeight = this._measureScrollHeight(); // The measuring class includes a 2px padding to workaround an issue with Chrome, // so we account for that extra space here by subtracting 4 (2px top + 2px bottom). - const height = textarea.scrollHeight - 4; + const height = Math.max(scrollHeight, this._cachedPlaceholderHeight || 0); // Use the scrollHeight to know how large the textarea *would* be if fit its entire value. textarea.style.height = `${height}px`; - textarea.classList.remove(this._measuringClass); - textarea.placeholder = placeholderText; this._ngZone.runOutsideAngular(() => { if (typeof requestAnimationFrame !== 'undefined') { diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts index 3e89d0efa63e..032366acfd57 100644 --- a/src/material/input/input.spec.ts +++ b/src/material/input/input.spec.ts @@ -1719,7 +1719,7 @@ describe('MatFormField default options', () => { }); describe('MatInput with textarea autosize', () => { - it('should not calculate wrong content height due to long placeholders', () => { + it('should adjust height due to long placeholders', () => { const fixture = createComponent(AutosizeTextareaWithLongPlaceholder); fixture.detectChanges(); @@ -1735,8 +1735,8 @@ describe('MatInput with textarea autosize', () => { autosize.resizeToFitContent(true); - expect(textarea.clientHeight).toBe(heightWithLongPlaceholder, - 'Expected the textarea height to be the same with a long placeholder.'); + expect(textarea.clientHeight).toBeLessThan(heightWithLongPlaceholder, + 'Expected the textarea height to be shorter with a long placeholder.'); }); it('should work in a tab', () => { diff --git a/tools/public_api_guard/cdk/text-field.d.ts b/tools/public_api_guard/cdk/text-field.d.ts index c9cedda5991d..062ad58ac1e6 100644 --- a/tools/public_api_guard/cdk/text-field.d.ts +++ b/tools/public_api_guard/cdk/text-field.d.ts @@ -31,6 +31,8 @@ export declare class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDe set maxRows(value: number); get minRows(): number; set minRows(value: number); + get placeholder(): string; + set placeholder(value: string); constructor(_elementRef: ElementRef, _platform: Platform, _ngZone: NgZone, document?: any); _noopInputHandler(): void; @@ -44,7 +46,7 @@ export declare class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDe static ngAcceptInputType_enabled: BooleanInput; static ngAcceptInputType_maxRows: NumberInput; static ngAcceptInputType_minRows: NumberInput; - static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵdir: i0.ɵɵDirectiveDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; }