Skip to content

Commit

Permalink
fix(cdk/text-field): autosize text areas using the placeholder (#22197)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
jermowery committed Mar 19, 2021
1 parent 08bbd50 commit 476a90b
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 21 deletions.
2 changes: 1 addition & 1 deletion scripts/check-mdc-tests-config.ts
Expand Up @@ -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'
],
Expand Down
62 changes: 58 additions & 4 deletions src/cdk/text-field/autosize.spec.ts
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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>(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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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>(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');
Expand Down Expand Up @@ -298,14 +351,15 @@ const textareaStyleReset = `
@Component({
template: `
<textarea cdkTextareaAutosize [cdkAutosizeMinRows]="minRows" [cdkAutosizeMaxRows]="maxRows"
#autosize="cdkTextareaAutosize">{{content}}</textarea>`,
#autosize="cdkTextareaAutosize" [placeholder]="placeholder">{{content}}</textarea>`,
styles: [textareaStyleReset],
})
class AutosizeTextAreaWithContent {
@ViewChild('autosize') autosize: CdkTextareaAutosize;
minRows: number | null = null;
maxRows: number | null = null;
content: string = '';
placeholder: string = '';
}

@Component({
Expand Down
50 changes: 38 additions & 12 deletions src/cdk/text-field/autosize.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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.
Expand All @@ -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') {
Expand Down
6 changes: 3 additions & 3 deletions src/material/input/input.spec.ts
Expand Up @@ -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();

Expand All @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion tools/public_api_guard/cdk/text-field.d.ts
Expand Up @@ -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<HTMLElement>, _platform: Platform, _ngZone: NgZone,
document?: any);
_noopInputHandler(): void;
Expand All @@ -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<CdkTextareaAutosize, "textarea[cdkTextareaAutosize]", ["cdkTextareaAutosize"], { "minRows": "cdkAutosizeMinRows"; "maxRows": "cdkAutosizeMaxRows"; "enabled": "cdkTextareaAutosize"; }, {}, never>;
static ɵdir: i0.ɵɵDirectiveDefWithMeta<CdkTextareaAutosize, "textarea[cdkTextareaAutosize]", ["cdkTextareaAutosize"], { "minRows": "cdkAutosizeMinRows"; "maxRows": "cdkAutosizeMaxRows"; "enabled": "cdkTextareaAutosize"; "placeholder": "placeholder"; }, {}, never>;
static ɵfac: i0.ɵɵFactoryDef<CdkTextareaAutosize, [null, null, null, { optional: true; }]>;
}

Expand Down

0 comments on commit 476a90b

Please sign in to comment.