Skip to content
This repository has been archived by the owner on Sep 5, 2024. It is now read-only.

Commit

Permalink
feat(textarea): textarea shrinking and resizing
Browse files Browse the repository at this point in the history
* Changes the textarea behavior to consider the "rows" attribute as the minimum, instead of the maximum, when auto-expanding the element.
* Adds a handle for vertically resizing the textarea element.
* Simplifies the logic for determining the textarea height.
* Makes the textarea sizing more accurate by using scrollHeight directly, instead of depending on the line height and amount of rows.
* Avoids potential issues where the textarea wouldn't resize when adding a newline.

Closes #7649.
  • Loading branch information
crisbeto committed Apr 11, 2016
1 parent 9245f54 commit 7d578e2
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 72 deletions.
6 changes: 5 additions & 1 deletion src/components/input/input-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,14 @@ md-input-container.md-THEME_NAME-theme {
color: '{{foreground-2}}';
}
}
&.md-input-focused {
&.md-input-focused,
&.md-input-resized {
.md-input {
border-color: '{{primary-color}}';
}
}

&.md-input-focused {
label {
color: '{{primary-color}}';
}
Expand Down
157 changes: 106 additions & 51 deletions src/components/input/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ function labelDirective() {
* @param {string=} placeholder An alternative approach to using aria-label when the label is not
* PRESENT. The placeholder text is copied to the aria-label attribute.
* @param md-no-autogrow {boolean=} When present, textareas will not grow automatically.
* @param md-no-resize {boolean=} Disables the textarea resize handle.
* @param md-no-asterisk {boolean=} When present, asterisk will not be appended to required inputs label
* @param md-detect-hidden {boolean=} When present, textareas will be sized properly when they are
* revealed after being hidden. This is off by default for performance reasons because it
Expand Down Expand Up @@ -257,7 +258,7 @@ function labelDirective() {
*
*/

function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout, $mdGesture) {
return {
restrict: 'E',
require: ['^?mdInputContainer', '?ngModel'],
Expand Down Expand Up @@ -375,9 +376,11 @@ function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
}

function setupTextarea() {
if (attr.hasOwnProperty('mdNoAutogrow')) {
return;
}
var isAutogrowing = !attr.hasOwnProperty('mdNoAutogrow');

attachResizeHandle();

if (!isAutogrowing) return;

// Can't check if height was or not explicity set,
// so rows attribute will take precedence if present
Expand All @@ -391,78 +394,130 @@ function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
$mdUtil.nextTick(growTextarea);
}, 10, false);

// We can hook into Angular's pipeline, instead of registering a new listener.
// Note that we should use `$parsers`, as opposed to `$viewChangeListeners` which
// was used before, because `$viewChangeListeners` don't fire if the input is
// invalid.
// We could leverage ngModel's $parsers here, however it
// isn't reliable, because Angular trims the input by default,
// which means that growTextarea won't fire when newlines and
// spaces are added.
element.on('input', growTextarea);

// We should still use the $formatters, because they fire when
// the value was changed from outside the textarea.
if (hasNgModel) {
ngModelCtrl.$formatters.unshift(pipelineListener);
ngModelCtrl.$parsers.unshift(pipelineListener);
} else {
// Note that it's safe to use the `input` event since we're not supporting IE9 and below.
element.on('input', growTextarea);
ngModelCtrl.$formatters.push(formattersListener);
}

if (!minRows) {
element
.attr('rows', 1)
.on('scroll', onScroll);
element.attr('rows', 1);
}

angular.element($window).on('resize', growTextarea);

scope.$on('$destroy', function() {
angular.element($window).off('resize', growTextarea);
});
scope.$on('$destroy', disableAutogrow);

function growTextarea() {
// temporarily disables element's flex so its height 'runs free'
element
.addClass('md-no-flex')
.attr('rows', 1);

if (minRows) {
if (!lineHeight) {
node.style.minHeight = 0;
lineHeight = element.prop('clientHeight');
node.style.minHeight = null;
}
.attr('rows', 1)
.css('height', 'auto')
.addClass('md-no-flex');

var height = getHeight();

var newRows = Math.round( Math.round(getHeight() / lineHeight) );
var rowsToSet = Math.min(newRows, minRows);
if (!lineHeight) {
lineHeight = element.prop('offsetHeight');
}

element
.css('height', lineHeight * rowsToSet + 'px')
.attr('rows', rowsToSet)
.toggleClass('_md-textarea-scrollable', newRows >= minRows);
if (minRows && lineHeight) {
height = Math.max(height, lineHeight * minRows);
}

} else {
element.css('height', 'auto');
node.scrollTop = 0;
var height = getHeight();
if (height) element.css('height', height + 'px');
if (lineHeight) {
element.attr('rows', Math.round(height / lineHeight));
}

element.removeClass('md-no-flex');
element
.css('height', height + 'px')
.removeClass('md-no-flex');
}

function getHeight() {
var offsetHeight = node.offsetHeight;
var line = node.scrollHeight - offsetHeight;
return offsetHeight + (line > 0 ? line : 0);
return offsetHeight + Math.max(line, 0);
}

function formattersListener(value) {
$mdUtil.nextTick(growTextarea);
return value;
}

function onScroll(e) {
node.scrollTop = 0;
// for smooth new line adding
var line = node.scrollHeight - node.offsetHeight;
var height = node.offsetHeight + line;
node.style.height = height + 'px';
function disableAutogrow() {
if (!isAutogrowing) return;

isAutogrowing = false;
angular.element($window).off('resize', growTextarea);
element
.attr('md-no-autogrow', '')
.off('input', growTextarea);

if (hasNgModel) {
var listenerIndex = ngModelCtrl.$formatters.indexOf(formattersListener);

if (listenerIndex > -1) {
ngModelCtrl.$formatters.splice(listenerIndex, 1);
}
}
}

function pipelineListener(value) {
growTextarea();
return value;
function attachResizeHandle() {
if (attr.hasOwnProperty('mdNoResize')) return;

var handle = angular.element('<div class="md-resize-handle"></div>');
var isDragging = false;
var dragStart = null;
var startHeight = 0;
var container = containerCtrl.element;

element.after(handle);
handle.on('mousedown', onMouseDown);
$mdGesture.register(handle, 'drag', { horizontal: false });

container
.on('$md.dragstart', onDragStart)
.on('$md.drag', onDrag)
.on('$md.dragend', onDragEnd);

scope.$on('$destroy', function() {
handle.off('mousedown', onMouseDown);
container
.off('$md.dragstart', onDragStart)
.off('$md.drag', onDrag)
.off('$md.dragend', onDragEnd);
});

function onMouseDown(ev) {
ev.preventDefault();
isDragging = true;
dragStart = ev.clientY;
startHeight = parseFloat(element.css('height')) || element.prop('offsetHeight');
}

function onDragStart(ev) {
if (!isDragging) return;
ev.preventDefault();
disableAutogrow();
container.addClass('md-input-resized');
}

function onDrag(ev) {
if (!isDragging) return;
element.css('height', startHeight + (ev.pointer.y - dragStart) + 'px');
}

function onDragEnd(ev) {
if (!isDragging) return;
isDragging = false;
container.removeClass('md-input-resized');
}
}

// Attach a watcher to detect when the textarea gets shown.
Expand Down
21 changes: 15 additions & 6 deletions src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ $icon-top-offset: ($icon-offset - $input-padding-top - $input-border-width-focus

$icon-float-focused-top: -8px !default;

$input-resize-handle-height: 10px !default;

md-input-container {
@include pie-clearfix();
display: inline-block;
Expand All @@ -46,6 +48,16 @@ md-input-container {
min-width: 1px;
}

.md-resize-handle {
position: absolute;
bottom: $input-error-height - $input-border-width-default * 2;
left: 0;
height: $input-resize-handle-height;
background: transparent;
width: 100%;
cursor: ns-resize;
}

> md-icon {
position: absolute;
top: $icon-top-offset;
Expand Down Expand Up @@ -88,14 +100,10 @@ md-input-container {
-ms-flex-preferred-size: auto; //IE fix
}

&._md-textarea-scrollable,
&[md-no-autogrow] {
overflow: auto;
}

// The height usually gets set to 1 line by `.md-input`.
&[md-no-autogrow] {
height: auto;
overflow: auto;
}
}

Expand Down Expand Up @@ -299,7 +307,8 @@ md-input-container {

// Use wide border in error state or in focused state
&.md-input-focused .md-input,
.md-input.ng-invalid.ng-dirty {
.md-input.ng-invalid.ng-dirty,
&.md-input-resized .md-input {
padding-bottom: 0; // Increase border width by 1px, decrease padding by 1
border-width: 0 0 $input-border-width-focused 0;
}
Expand Down
70 changes: 56 additions & 14 deletions src/components/input/input.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,19 +489,34 @@ describe('md-input-container directive', function() {
var oldHeight = textarea.offsetHeight;
ngTextarea.val('Multiple\nlines\nof\ntext');
ngTextarea.triggerHandler('input');
scope.$apply();
expect(textarea.offsetHeight).toBeGreaterThan(oldHeight);
});

it('should auto-size the textarea in response to an outside ngModel change', function() {
createAndAppendElement('ng-model="model"');
var oldHeight = textarea.offsetHeight;
scope.model = '1\n2\n3\n';
$timeout.flush();
var newHeight = textarea.offsetHeight;
expect(newHeight).toBeGreaterThan(oldHeight);
expect(textarea.offsetHeight).toBeGreaterThan(oldHeight);
});

it('should allow the textarea to shrink if text is being deleted', function() {
createAndAppendElement();
ngTextarea.val('Multiple\nlines\nof\ntext');
ngTextarea.triggerHandler('input');
var oldHeight = textarea.offsetHeight;

ngTextarea.val('One line of text');
ngTextarea.triggerHandler('input');

expect(textarea.offsetHeight).toBeLessThan(oldHeight);
});

it('should not auto-size if md-no-autogrow is present', function() {
createAndAppendElement('md-no-autogrow');
var oldHeight = textarea.offsetHeight;
ngTextarea.val('Multiple\nlines\nof\ntext');
ngTextarea.triggerHandler('input');
scope.$apply();
$timeout.flush();
var newHeight = textarea.offsetHeight;
expect(newHeight).toEqual(oldHeight);
});
Expand All @@ -515,33 +530,60 @@ describe('md-input-container directive', function() {
ngTextarea.val('Multiple\nlines\nof\ntext');
ngTextarea.triggerHandler('input');
scope.$apply();
$timeout.flush();

// Textarea should still be hidden.
expect(textarea.offsetHeight).toBe(0);

scope.parentHidden = false;
scope.$apply();

$timeout.flush();
var newHeight = textarea.offsetHeight;
expect(textarea.offsetHeight).toBeGreaterThan(oldHeight);
});

it('should make the textarea scrollable once it has reached the row limit', function() {
var scrollableClass = '_md-textarea-scrollable';
it('should set the rows attribute as the user types', function() {
createAndAppendElement();
expect(textarea.rows).toBe(1);

createAndAppendElement('rows="2"');
ngTextarea.val('1\n2\n3');
ngTextarea.triggerHandler('input');
expect(textarea.rows).toBe(3);
});

ngTextarea.val('Single line of text');
it('should not allow the textarea rows to be less than the minimum number of rows', function() {
createAndAppendElement('rows="5"');
ngTextarea.val('1\n2\n3\n4\n5\n6\n7\n');
ngTextarea.triggerHandler('input');
expect(textarea.rows).toBe(7);

expect(ngTextarea.hasClass(scrollableClass)).toBe(false);
ngTextarea.val('');
ngTextarea.triggerHandler('input');
expect(textarea.rows).toBe(5);
});

ngTextarea.val('Multiple\nlines\nof\ntext');
it('should add a handle for resizing the textarea', function() {
createAndAppendElement();
expect(element.querySelector('.md-resize-handle')).toBeTruthy();
});

it('should disable auto-sizing if the handle gets dragged', function() {
createAndAppendElement();
var handle = angular.element(element.querySelector('.md-resize-handle'));

ngTextarea.val('1\n2\n3');
ngTextarea.triggerHandler('input');
var oldHeight = textarea.offsetHeight;

handle.triggerHandler('mousedown');
ngElement.triggerHandler('$md.dragstart');
ngTextarea.val('1\n2\n3\n4\n5\n6');
ngTextarea.triggerHandler('input');
expect(textarea.offsetHeight).toBe(oldHeight);
});

expect(ngTextarea.hasClass(scrollableClass)).toBe(true);
it('should not add the handle if md-no-resize is present', function() {
createAndAppendElement('md-no-resize');
expect(element.querySelector('.md-resize-handle')).toBeFalsy();
});
});

Expand Down

0 comments on commit 7d578e2

Please sign in to comment.