diff --git a/package.json b/package.json index 835fe16491eb..97fd10eff170 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/minimist": "^1.1.28", "@types/node": "^6.0.34", "@types/run-sequence": "0.0.27", + "@types/rx": "^2.5.33", "browserstacktunnel-wrapper": "^2.0.0", "conventional-changelog": "^1.1.0", "express": "^4.14.0", diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index 2130e211064e..1bef8bfd2df4 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -174,3 +174,9 @@

Textarea

+ + + +

textarea autosize

+ +
diff --git a/src/demo-app/input/input-demo.scss b/src/demo-app/input/input-demo.scss index 97a9f93ea2df..b70dd6f1e573 100644 --- a/src/demo-app/input/input-demo.scss +++ b/src/demo-app/input/input-demo.scss @@ -18,3 +18,11 @@ .demo-card { margin: 16px; } + +.demo-textarea { + resize: none; + border: none; + overflow: auto; + padding: 0; + background: lightblue; +} diff --git a/src/lib/input/autosize.spec.ts b/src/lib/input/autosize.spec.ts new file mode 100644 index 000000000000..102ec41875db --- /dev/null +++ b/src/lib/input/autosize.spec.ts @@ -0,0 +1,127 @@ +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed, async} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {MdInputModule} from './input'; +import {MdTextareaAutosize} from './autosize'; + + +describe('MdTextareaAutosize', () => { + let fixture: ComponentFixture; + let textarea: HTMLTextAreaElement; + let autosize: MdTextareaAutosize; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdInputModule], + declarations: [AutosizeTextAreaWithContent, AutosizeTextAreaWithValue], + }); + + TestBed.compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AutosizeTextAreaWithContent); + fixture.detectChanges(); + + textarea = fixture.nativeElement.querySelector('textarea'); + autosize = fixture.debugElement.query( + By.directive(MdTextareaAutosize)).injector.get(MdTextareaAutosize); + }); + + it('should resize the textarea based on its content', () => { + let previousHeight = textarea.offsetHeight; + + fixture.componentInstance.content = ` + 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.”`; + + // Manually call resizeToFitContent instead of faking an `input` event. + fixture.detectChanges(); + autosize.resizeToFitContent(); + + expect(textarea.offsetHeight) + .toBeGreaterThan(previousHeight, 'Expected textarea to have grown with added content.'); + expect(textarea.offsetHeight) + .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); + + previousHeight = textarea.offsetHeight; + fixture.componentInstance.content += ` + 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 + From my books surcease of sorrow—sorrow for the lost Lenore— + For the rare and radiant maiden whom the angels name Lenore— + Nameless here for evermore.`; + + fixture.detectChanges(); + autosize.resizeToFitContent(); + + expect(textarea.offsetHeight) + .toBeGreaterThan(previousHeight, 'Expected textarea to have grown with added content.'); + expect(textarea.offsetHeight) + .toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight'); + }); + + it('should set a min-width based on minRows', () => { + expect(textarea.style.minHeight).toBeFalsy(); + + fixture.componentInstance.minRows = 4; + fixture.detectChanges(); + + expect(textarea.style.minHeight).toBeDefined('Expected a min-height to be set via minRows.'); + + let previousMinHeight = parseInt(textarea.style.minHeight); + fixture.componentInstance.minRows = 6; + fixture.detectChanges(); + + expect(parseInt(textarea.style.minHeight)) + .toBeGreaterThan(previousMinHeight, 'Expected increased min-height with minRows increase.'); + }); + + it('should set a max-width based on maxRows', () => { + expect(textarea.style.maxHeight).toBeFalsy(); + + fixture.componentInstance.maxRows = 4; + fixture.detectChanges(); + + expect(textarea.style.maxHeight).toBeDefined('Expected a max-height to be set via maxRows.'); + + let previousMaxHeight = parseInt(textarea.style.maxHeight); + fixture.componentInstance.maxRows = 6; + fixture.detectChanges(); + + expect(parseInt(textarea.style.maxHeight)) + .toBeGreaterThan(previousMaxHeight, 'Expected increased max-height with maxRows increase.'); + }); +}); + + +// Styles to reset padding and border to make measurement comparisons easier. +const textareaStyleReset = ` + textarea { + padding: 0; + border: none; + overflow: auto; + }`; + +@Component({ + template: ``, + styles: [textareaStyleReset], +}) +class AutosizeTextAreaWithContent { + minRows: number = null; + maxRows: number = null; + content: string = ''; +} + +@Component({ + template: ``, + styles: [textareaStyleReset], +}) +class AutosizeTextAreaWithValue { + value: string = ''; +} diff --git a/src/lib/input/autosize.ts b/src/lib/input/autosize.ts new file mode 100644 index 000000000000..458ce88dc855 --- /dev/null +++ b/src/lib/input/autosize.ts @@ -0,0 +1,81 @@ +import {Directive, ElementRef, Input, OnInit} from '@angular/core'; + + +/** + * Directive to automatically resize a textarea to fit its content. + */ +@Directive({ + selector: 'textarea[md-autosize]', + host: { + '(input)': 'resizeToFitContent()', + '[style.min-height]': '_minHeight', + '[style.max-height]': '_maxHeight', + }, +}) +export class MdTextareaAutosize implements OnInit { + /** Minimum number of rows for this textarea. */ + @Input() minRows: number; + + /** Maximum number of rows for this textarea. */ + @Input() maxRows: number; + + /** Cached height of a textarea with a single row. */ + private _cachedLineHeight: number; + + constructor(private _elementRef: ElementRef) { } + + /** The minimum height of the textarea as determined by minRows. */ + get _minHeight() { + return this.minRows ? `${this.minRows * this._cachedLineHeight}px` : null; + } + + /** The maximum height of the textarea as determined by maxRows. */ + get _maxHeight() { + return this.maxRows ? `${this.maxRows * this._cachedLineHeight}px` : null; + } + + ngOnInit() { + this._cacheTextareaLineHeight(); + this.resizeToFitContent(); + } + + /** + * Cache the hight of a single-row textarea. + * + * We need to know how large a single "row" of a textarea is in order to apply minRows and + * maxRows. For the initial version, we will assume that the height of a single line in the + * textarea does not ever change. + */ + private _cacheTextareaLineHeight(): void { + let textarea = this._elementRef.nativeElement as HTMLTextAreaElement; + + // Use a clone element because we have to override some styles. + let textareaClone = textarea.cloneNode(false) as HTMLTextAreaElement; + textareaClone.rows = 1; + + // Use `position: absolute` so that this doesn't cause a browser layout and use + // `visibility: hidden` so that nothing is rendered. Clear any other styles that + // would affect the height. + textareaClone.style.position = 'absolute'; + textareaClone.style.visibility = 'hidden'; + textareaClone.style.border = 'none'; + textareaClone.style.padding = ''; + textareaClone.style.height = ''; + textareaClone.style.minHeight = ''; + textareaClone.style.maxHeight = ''; + + textarea.parentNode.appendChild(textareaClone); + this._cachedLineHeight = textareaClone.offsetHeight; + textarea.parentNode.removeChild(textareaClone); + } + + /** Resize the textarea to fit its content. */ + resizeToFitContent() { + let textarea = this._elementRef.nativeElement as HTMLTextAreaElement; + // Reset the textarea height to auto in order to shrink back to its default size. + textarea.style.height = 'auto'; + + // Use the scrollHeight to know how large the textarea *would* be if fit its entire value. + textarea.style.height = `${textarea.scrollHeight}px`; + } +} diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index 3d8b10a3c613..3003b3e3e71b 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -22,6 +22,7 @@ import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/for import {CommonModule} from '@angular/common'; import {MdError, coerceBooleanProperty} from '../core'; import {Observable} from 'rxjs/Observable'; +import {MdTextareaAutosize} from './autosize'; const noop = () => {}; @@ -360,9 +361,9 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange @NgModule({ - declarations: [MdPlaceholder, MdInput, MdHint], + declarations: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize], imports: [CommonModule, FormsModule], - exports: [MdPlaceholder, MdInput, MdHint], + exports: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize], }) export class MdInputModule { static forRoot(): ModuleWithProviders { diff --git a/src/lib/tsconfig.json b/src/lib/tsconfig.json index 616568e24c79..0d25173aaf70 100644 --- a/src/lib/tsconfig.json +++ b/src/lib/tsconfig.json @@ -22,7 +22,8 @@ "../../node_modules/@types" ], "types": [ - "jasmine" + "jasmine", + "rx/rx.all" ] }, "angularCompilerOptions": {