From 7d578e2508c6394606cca500ee73ab18ddd6a70f Mon Sep 17 00:00:00 2001 From: crisbeto Date: Mon, 11 Apr 2016 23:29:22 +0200 Subject: [PATCH] feat(textarea): textarea shrinking and resizing * 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. --- src/components/input/input-theme.scss | 6 +- src/components/input/input.js | 157 +++++++++++++++++--------- src/components/input/input.scss | 21 +++- src/components/input/input.spec.js | 70 +++++++++--- 4 files changed, 182 insertions(+), 72 deletions(-) diff --git a/src/components/input/input-theme.scss b/src/components/input/input-theme.scss index 022108e2220..7fd7c1fcc6a 100644 --- a/src/components/input/input-theme.scss +++ b/src/components/input/input-theme.scss @@ -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}}'; } diff --git a/src/components/input/input.js b/src/components/input/input.js index cee6929c94f..bf8edcdca63 100644 --- a/src/components/input/input.js +++ b/src/components/input/input.js @@ -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 @@ -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'], @@ -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 @@ -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('
'); + 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. diff --git a/src/components/input/input.scss b/src/components/input/input.scss index e3cdd06eb91..7ed471d2f1b 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -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; @@ -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; @@ -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; } } @@ -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; } diff --git a/src/components/input/input.spec.js b/src/components/input/input.spec.js index 0605cc6e26f..93b89ece887 100644 --- a/src/components/input/input.spec.js +++ b/src/components/input/input.spec.js @@ -489,10 +489,27 @@ 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() { @@ -500,8 +517,6 @@ describe('md-input-container directive', function() { 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); }); @@ -515,7 +530,6 @@ 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); @@ -523,25 +537,53 @@ describe('md-input-container directive', function() { 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(); }); });