Skip to content

Commit 8aa6e58

Browse files
committed
feat(textarea): add md-autosize directive
1 parent 6aa7e22 commit 8aa6e58

File tree

7 files changed

+228
-3
lines changed

7 files changed

+228
-3
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@types/minimist": "^1.1.28",
4949
"@types/node": "^6.0.34",
5050
"@types/run-sequence": "0.0.27",
51+
"@types/rx": "^2.5.33",
5152
"browserstacktunnel-wrapper": "^2.0.0",
5253
"conventional-changelog": "^1.1.0",
5354
"express": "^4.14.0",

src/demo-app/input/input-demo.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,9 @@ <h4>Textarea</h4>
174174
</tr>
175175
</table>
176176
</md-card>
177+
178+
179+
<md-card>
180+
<h2>textarea autosize</h2>
181+
<textarea md-autosize class="demo-textarea"></textarea>
182+
</md-card>

src/demo-app/input/input-demo.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,11 @@
1818
.demo-card {
1919
margin: 16px;
2020
}
21+
22+
.demo-textarea {
23+
resize: none;
24+
border: none;
25+
overflow: auto;
26+
padding: 0;
27+
background: lightblue;
28+
}

src/lib/input/autosize.spec.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {Component} from '@angular/core';
2+
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
import {MdInputModule} from './input';
5+
import {MdTextareaAutosize} from './autosize';
6+
7+
8+
describe('MdTextareaAutosize', () => {
9+
let fixture: ComponentFixture<AutosizeTextAreaWithContent>;
10+
let textarea: HTMLTextAreaElement;
11+
let autosize: MdTextareaAutosize;
12+
13+
beforeEach(async(() => {
14+
TestBed.configureTestingModule({
15+
imports: [MdInputModule],
16+
declarations: [AutosizeTextAreaWithContent, AutosizeTextAreaWithValue],
17+
});
18+
19+
TestBed.compileComponents();
20+
}));
21+
22+
beforeEach(() => {
23+
fixture = TestBed.createComponent(AutosizeTextAreaWithContent);
24+
fixture.detectChanges();
25+
26+
textarea = fixture.nativeElement.querySelector('textarea');
27+
autosize = fixture.debugElement.query(
28+
By.directive(MdTextareaAutosize)).injector.get(MdTextareaAutosize);
29+
});
30+
31+
it('should resize the textarea based on its content', () => {
32+
let previousHeight = textarea.offsetHeight;
33+
34+
fixture.componentInstance.content = `
35+
Once upon a midnight dreary, while I pondered, weak and weary,
36+
Over many a quaint and curious volume of forgotten lore—
37+
While I nodded, nearly napping, suddenly there came a tapping,
38+
As of some one gently rapping, rapping at my chamber door.
39+
“’Tis some visitor,” I muttered, “tapping at my chamber door—
40+
Only this and nothing more.”`;
41+
42+
// Manually call resizeToFitContent instead of faking an `input` event.
43+
fixture.detectChanges();
44+
autosize.resizeToFitContent();
45+
46+
expect(textarea.offsetHeight)
47+
.toBeGreaterThan(previousHeight, 'Expected textarea to have grown with added content.');
48+
expect(textarea.offsetHeight)
49+
.toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight');
50+
51+
previousHeight = textarea.offsetHeight;
52+
fixture.componentInstance.content += `
53+
Ah, distinctly I remember it was in the bleak December;
54+
And each separate dying ember wrought its ghost upon the floor.
55+
Eagerly I wished the morrow;—vainly I had sought to borrow
56+
From my books surcease of sorrow—sorrow for the lost Lenore—
57+
For the rare and radiant maiden whom the angels name Lenore—
58+
Nameless here for evermore.`;
59+
60+
fixture.detectChanges();
61+
autosize.resizeToFitContent();
62+
63+
expect(textarea.offsetHeight)
64+
.toBeGreaterThan(previousHeight, 'Expected textarea to have grown with added content.');
65+
expect(textarea.offsetHeight)
66+
.toBe(textarea.scrollHeight, 'Expected textarea height to match its scrollHeight');
67+
});
68+
69+
it('should set a min-width based on minRows', () => {
70+
expect(textarea.style.minHeight).toBeFalsy();
71+
72+
fixture.componentInstance.minRows = 4;
73+
fixture.detectChanges();
74+
75+
expect(textarea.style.minHeight).toBeDefined('Expected a min-height to be set via minRows.');
76+
77+
let previousMinHeight = parseInt(textarea.style.minHeight);
78+
fixture.componentInstance.minRows = 6;
79+
fixture.detectChanges();
80+
81+
expect(parseInt(textarea.style.minHeight))
82+
.toBeGreaterThan(previousMinHeight, 'Expected increased min-height with minRows increase.');
83+
});
84+
85+
it('should set a max-width based on maxRows', () => {
86+
expect(textarea.style.maxHeight).toBeFalsy();
87+
88+
fixture.componentInstance.maxRows = 4;
89+
fixture.detectChanges();
90+
91+
expect(textarea.style.maxHeight).toBeDefined('Expected a max-height to be set via maxRows.');
92+
93+
let previousMaxHeight = parseInt(textarea.style.maxHeight);
94+
fixture.componentInstance.maxRows = 6;
95+
fixture.detectChanges();
96+
97+
expect(parseInt(textarea.style.maxHeight))
98+
.toBeGreaterThan(previousMaxHeight, 'Expected increased max-height with maxRows increase.');
99+
});
100+
});
101+
102+
103+
// Styles to reset padding and border to make measurement comparisons easier.
104+
const textareaStyleReset = `
105+
textarea {
106+
padding: 0;
107+
border: none;
108+
overflow: auto;
109+
}`;
110+
111+
@Component({
112+
template: `<textarea md-autosize [minRows]="minRows" [maxRows]="maxRows">{{content}}</textarea>`,
113+
styles: [textareaStyleReset],
114+
})
115+
class AutosizeTextAreaWithContent {
116+
minRows: number = null;
117+
maxRows: number = null;
118+
content: string = '';
119+
}
120+
121+
@Component({
122+
template: `<textarea md-autosize [value]="value"></textarea>`,
123+
styles: [textareaStyleReset],
124+
})
125+
class AutosizeTextAreaWithValue {
126+
value: string = '';
127+
}

src/lib/input/autosize.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {Directive, ElementRef, Input, OnInit} from '@angular/core';
2+
3+
4+
/**
5+
* Directive to automatically resize a textarea to fit its content.
6+
*/
7+
@Directive({
8+
selector: 'textarea[md-autosize]',
9+
host: {
10+
'(input)': 'resizeToFitContent()',
11+
'[style.min-height]': '_minHeight',
12+
'[style.max-height]': '_maxHeight',
13+
},
14+
})
15+
export class MdTextareaAutosize implements OnInit {
16+
/** Minimum number of rows for this textarea. */
17+
@Input() minRows: number;
18+
19+
/** Maximum number of rows for this textarea. */
20+
@Input() maxRows: number;
21+
22+
/** Cached height of a textarea with a single row. */
23+
private _cachedLineHeight: number;
24+
25+
constructor(private _elementRef: ElementRef) { }
26+
27+
/** The minimum height of the textarea as determined by minRows. */
28+
get _minHeight() {
29+
return this.minRows ? `${this.minRows * this._cachedLineHeight}px` : null;
30+
}
31+
32+
/** The maximum height of the textarea as determined by maxRows. */
33+
get _maxHeight() {
34+
return this.maxRows ? `${this.maxRows * this._cachedLineHeight}px` : null;
35+
}
36+
37+
ngOnInit() {
38+
this._cacheTextareaLineHeight();
39+
this.resizeToFitContent();
40+
}
41+
42+
/**
43+
* Cache the hight of a single-row textarea.
44+
*
45+
* We need to know how large a single "row" of a textarea is in order to apply minRows and
46+
* maxRows. For the initial version, we will assume that the height of a single line in the
47+
* textarea does not ever change.
48+
*/
49+
private _cacheTextareaLineHeight(): void {
50+
let textarea = this._elementRef.nativeElement as HTMLTextAreaElement;
51+
52+
// Use a clone element because we have to override some styles.
53+
let textareaClone = textarea.cloneNode(false) as HTMLTextAreaElement;
54+
textareaClone.rows = 1;
55+
56+
// Use `position: absolute` so that this doesn't cause a browser layout and use
57+
// `visibility: hidden` so that nothing is rendered. Clear any other styles that
58+
// would affect the height.
59+
textareaClone.style.position = 'absolute';
60+
textareaClone.style.visibility = 'hidden';
61+
textareaClone.style.border = 'none';
62+
textareaClone.style.padding = '';
63+
textareaClone.style.height = '';
64+
textareaClone.style.minHeight = '';
65+
textareaClone.style.maxHeight = '';
66+
67+
textarea.parentNode.appendChild(textareaClone);
68+
this._cachedLineHeight = textareaClone.offsetHeight;
69+
textarea.parentNode.removeChild(textareaClone);
70+
}
71+
72+
/** Resize the textarea to fit its content. */
73+
resizeToFitContent() {
74+
let textarea = this._elementRef.nativeElement as HTMLTextAreaElement;
75+
// Reset the textarea height to auto in order to shrink back to its default size.
76+
textarea.style.height = 'auto';
77+
78+
// Use the scrollHeight to know how large the textarea *would* be if fit its entire value.
79+
textarea.style.height = `${textarea.scrollHeight}px`;
80+
}
81+
}

src/lib/input/input.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {NG_VALUE_ACCESSOR, ControlValueAccessor, FormsModule} from '@angular/for
2222
import {CommonModule} from '@angular/common';
2323
import {MdError, coerceBooleanProperty} from '../core';
2424
import {Observable} from 'rxjs/Observable';
25+
import {MdTextareaAutosize} from './autosize';
2526

2627

2728
const noop = () => {};
@@ -360,9 +361,9 @@ export class MdInput implements ControlValueAccessor, AfterContentInit, OnChange
360361

361362

362363
@NgModule({
363-
declarations: [MdPlaceholder, MdInput, MdHint],
364+
declarations: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize],
364365
imports: [CommonModule, FormsModule],
365-
exports: [MdPlaceholder, MdInput, MdHint],
366+
exports: [MdPlaceholder, MdInput, MdHint, MdTextareaAutosize],
366367
})
367368
export class MdInputModule {
368369
static forRoot(): ModuleWithProviders {

src/lib/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"../../node_modules/@types"
2323
],
2424
"types": [
25-
"jasmine"
25+
"jasmine",
26+
"rx/rx.all"
2627
]
2728
},
2829
"angularCompilerOptions": {

0 commit comments

Comments
 (0)