From af2e4aa6719fc95889c49e6d28aa68aba3706e9e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Mon, 11 Apr 2016 23:29:22 +0200 Subject: [PATCH] * 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. * Adds the option to specify a maximum number of rows for a textarea via the `max-rows` attribute. Fixes #7649. Fixes #5919. Fixes #8135. BREAKING CHANGE This changes the behavior from considering the "rows" attribute as the maximum to considering it as the minimum. --- src/components/input/input-theme.scss | 6 +- src/components/input/input.js | 192 +++++++++++++++++++------- src/components/input/input.scss | 21 ++- src/components/input/input.spec.js | 83 +++++++++-- 4 files changed, 230 insertions(+), 72 deletions(-) diff --git a/src/components/input/input-theme.scss b/src/components/input/input-theme.scss index 022108e222..7fd7c1fcc6 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 51d1aa00d3..25676e78aa 100644 --- a/src/components/input/input.js +++ b/src/components/input/input.js @@ -174,6 +174,8 @@ function labelDirective() { * 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-asterisk {boolean=} When present, an asterisk will not be appended to the inputs floating label + * @param md-no-resize {boolean=} Disables the textarea resize handle. + * @param {number=} max-rows The maximum amount of rows for a textarea. * @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 * guarantees a reflow every digest cycle. @@ -259,9 +261,21 @@ function labelDirective() { * error animation effects. Therefore, it is *not* advised to use the Layout system inside of the * `` tags. Instead, use relative or absolute positioning. * + * + *

Textarea directive

+ * The `textarea` element within a `md-input-container` has the following specific behavior: + * - By default the `textarea` grows as the user types. This can be disabled via the `md-no-autogrow` + * attribute. + * - If a `textarea` has the `rows` attribute, it will treat the `rows` as the minimum height and will + * continue growing as the user types. For example a textarea with `rows="3"` will be 3 lines of text + * high initially. If no rows are specified, the directive defaults to 1. + * - If you wan't a `textarea` to stop growing at a certain point, you can specify the `max-rows` attribute. + * - The textarea's bottom border acts as a handle which users can drag, in order to resize the element vertically. + * Once the user has resized a `textarea`, the autogrowing functionality becomes disabled. If you don't want a + * `textarea` to be resizeable by the user, you can add the `md-no-resize` attribute. */ -function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) { +function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout, $mdGesture) { return { restrict: 'E', require: ['^?mdInputContainer', '?ngModel'], @@ -379,13 +393,16 @@ 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 var minRows = attr.hasOwnProperty('rows') ? parseInt(attr.rows) : NaN; + var maxRows = attr.hasOwnProperty('maxRows') ? parseInt(attr.maxRows) : NaN; var lineHeight = null; var node = element[0]; @@ -395,78 +412,151 @@ 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 newRows = Math.round( Math.round(getHeight() / lineHeight) ); - var rowsToSet = Math.min(newRows, minRows); + var height = getHeight(); - element - .css('height', lineHeight * rowsToSet + 'px') - .attr('rows', rowsToSet) - .toggleClass('_md-textarea-scrollable', newRows >= minRows); + if (!lineHeight) { + // offsetHeight includes padding which can throw off our value + lineHeight = element.css('padding', 0).prop('offsetHeight'); + element.css('padding', null); + } - } else { - element.css('height', 'auto'); - node.scrollTop = 0; - var height = getHeight(); - if (height) element.css('height', height + 'px'); + if (minRows && lineHeight) { + height = Math.max(height, lineHeight * minRows); + } + + if (maxRows && lineHeight) { + var maxHeight = lineHeight * maxRows; + + if (maxHeight < height) { + element.attr('md-no-autogrow', ''); + height = maxHeight; + } else { + element.removeAttr('md-no-autogrow'); + } + } + + 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 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 formattersListener(value) { + $mdUtil.nextTick(growTextarea); + return value; } - function pipelineListener(value) { - growTextarea(); - return value; + 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 attachResizeHandle() { + if (attr.hasOwnProperty('mdNoResize')) return; + + var handle = angular.element('
'); + var isDragging = false; + var dragStart = null; + var startHeight = 0; + var container = containerCtrl.element; + var dragGestureHandler = $mdGesture.register(handle, 'drag', { horizontal: false }); + + element.after(handle); + handle.on('mousedown', onMouseDown); + + container + .on('$md.dragstart', onDragStart) + .on('$md.drag', onDrag) + .on('$md.dragend', onDragEnd); + + scope.$on('$destroy', function() { + handle + .off('mousedown', onMouseDown) + .remove(); + + container + .off('$md.dragstart', onDragStart) + .off('$md.drag', onDrag) + .off('$md.dragend', onDragEnd); + + dragGestureHandler(); + handle = null; + container = null; + dragGestureHandler = null; + }); + + 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 e3cdd06eb9..7ed471d2f1 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 ee5dff14d7..3ad68ea3cd 100644 --- a/src/components/input/input.spec.js +++ b/src/components/input/input.spec.js @@ -542,10 +542,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() { @@ -553,8 +570,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); }); @@ -568,7 +583,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); @@ -576,25 +590,66 @@ 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); + }); + + 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'); + ngTextarea.triggerHandler('input'); + expect(textarea.rows).toBe(7); - ngTextarea.val('Single line of text'); + ngTextarea.val(''); ngTextarea.triggerHandler('input'); + expect(textarea.rows).toBe(5); + }); - expect(ngTextarea.hasClass(scrollableClass)).toBe(false); + it('should not let a textarea grow past its maximum number of rows', function() { + createAndAppendElement('max-rows="5"'); + ngTextarea.val('1\n2\n3'); + ngTextarea.triggerHandler('input'); + expect(textarea.rows).toBe(3); + expect(ngTextarea.attr('md-no-autogrow')).toBeUndefined(); - ngTextarea.val('Multiple\nlines\nof\ntext'); + ngTextarea.val('1\n2\n3\n4\n5\n6\n7\n8\n9'); + ngTextarea.triggerHandler('input'); + expect(textarea.rows).toBe(5); + expect(ngTextarea.attr('md-no-autogrow')).toBeDefined(); + }); + + 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(); }); });