From 526dbdb47a0239598adbe88fc550b6d8e383ceb6 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 13 Mar 2016 16:58:04 +0100 Subject: [PATCH] fix(textarea): scrolling, text selection, reduced DOM manipulation. * Fixes `mdSelectOnFocus` not working in Edge and being unreliable in Firefox. * Fixes `textarea` not being scrollable once it is past it's minimum number of rows. * Fixes `textarea` not being scrollable if `mdNoAutogrow` is specified. * Tries to reduce the number of event listeners and the amount of DOM manipulation when resizing the `textarea`. Closes #7487. --- src/components/input/demoBasicUsage/script.js | 2 +- src/components/input/input.js | 143 +++++++++++------- src/components/input/input.scss | 18 ++- src/components/input/input.spec.js | 22 ++- 4 files changed, 120 insertions(+), 65 deletions(-) diff --git a/src/components/input/demoBasicUsage/script.js b/src/components/input/demoBasicUsage/script.js index ac6f601ec0b..245165d4164 100644 --- a/src/components/input/demoBasicUsage/script.js +++ b/src/components/input/demoBasicUsage/script.js @@ -18,7 +18,7 @@ angular 'MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI ' + 'WY').split(' ').map(function(state) { return {abbrev: state}; - }) + }); }) .config(function($mdThemingProvider) { diff --git a/src/components/input/input.js b/src/components/input/input.js index 3dc2a11449d..5a5221f0509 100644 --- a/src/components/input/input.js +++ b/src/components/input/input.js @@ -250,7 +250,7 @@ function labelDirective() { * */ -function inputTextareaDirective($mdUtil, $window, $mdAria) { +function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) { return { restrict: 'E', require: ['^?mdInputContainer', '?ngModel'], @@ -365,84 +365,81 @@ function inputTextareaDirective($mdUtil, $window, $mdAria) { } function setupTextarea() { - if (angular.isDefined(element.attr('md-no-autogrow'))) { + if (attr.hasOwnProperty('mdNoAutogrow')) { return; } - var node = element[0]; - var container = containerCtrl.element[0]; - - var min_rows = NaN; - var lineHeight = null; - // can't check if height was or not explicity set, + // Can't check if height was or not explicity set, // so rows attribute will take precedence if present - if (node.hasAttribute('rows')) { - min_rows = parseInt(node.getAttribute('rows')); - } - - var onChangeTextarea = $mdUtil.debounce(growTextarea, 1); - - function pipelineListener(value) { - onChangeTextarea(); - return value; - } + var minRows = attr.hasOwnProperty('rows') ? parseInt(attr.rows) : NaN; + var lineHeight = null; + var node = element[0]; - if (ngModelCtrl) { - ngModelCtrl.$formatters.push(pipelineListener); - ngModelCtrl.$viewChangeListeners.push(pipelineListener); + // This timeout is necessary, because the browser needs a little bit + // of time to calculate the `clientHeight` and `scrollHeight`. + $timeout(function() { + $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. + if (hasNgModel) { + ngModelCtrl.$formatters.unshift(pipelineListener); + ngModelCtrl.$parsers.unshift(pipelineListener); } else { - onChangeTextarea(); + // Note that it's safe to use the `input` event since we're not supporting IE9 and below. + element.on('input', growTextarea); } - element.on('keydown input', onChangeTextarea); - - if (isNaN(min_rows)) { - element.attr('rows', '1'); - element.on('scroll', onScroll); + if (!minRows) { + element + .attr('rows', 1) + .on('scroll', onScroll); } - angular.element($window).on('resize', onChangeTextarea); + angular.element($window).on('resize', growTextarea); scope.$on('$destroy', function() { - angular.element($window).off('resize', onChangeTextarea); + angular.element($window).off('resize', growTextarea); }); function growTextarea() { - // sets the md-input-container height to avoid jumping around - container.style.height = container.offsetHeight + 'px'; - // temporarily disables element's flex so its height 'runs free' - element.addClass('md-no-flex'); - - if (isNaN(min_rows)) { - node.style.height = "auto"; - node.scrollTop = 0; - var height = getHeight(); - if (height) node.style.height = height + 'px'; - } else { - node.setAttribute("rows", 1); + element + .addClass('md-no-flex') + .attr('rows', 1); + if (minRows) { if (!lineHeight) { - node.style.minHeight = '0'; - + node.style.minHeight = 0; lineHeight = element.prop('clientHeight'); - node.style.minHeight = null; } - var rows = Math.min(min_rows, Math.round(node.scrollHeight / lineHeight)); - node.setAttribute("rows", rows); - node.style.height = lineHeight * rows + "px"; + var newRows = Math.round( Math.round(getHeight() / lineHeight) ); + var rowsToSet = Math.min(newRows, minRows); + + element + .css('height', lineHeight * rowsToSet + 'px') + .attr('rows', rowsToSet) + .toggleClass('_md-textarea-scrollable', newRows >= minRows); + + } else { + element.css('height', 'auto'); + node.scrollTop = 0; + var height = getHeight(); + if (height) element.css('height', height + 'px'); } - // reset everything back to normal element.removeClass('md-no-flex'); - container.style.height = 'auto'; } function getHeight() { - var line = node.scrollHeight - node.offsetHeight; - return node.offsetHeight + (line > 0 ? line : 0); + var offsetHeight = node.offsetHeight; + var line = node.scrollHeight - offsetHeight; + return offsetHeight + (line > 0 ? line : 0); } function onScroll(e) { @@ -453,8 +450,13 @@ function inputTextareaDirective($mdUtil, $window, $mdAria) { node.style.height = height + 'px'; } + function pipelineListener(value) { + growTextarea(); + return value; + } + // Attach a watcher to detect when the textarea gets shown. - if (angular.isDefined(element.attr('md-detect-hidden'))) { + if (attr.hasOwnProperty('mdDetectHidden')) { var handleHiddenChange = function() { var wasHidden = false; @@ -616,7 +618,7 @@ function placeholderDirective($log) { * * */ -function mdSelectOnFocusDirective() { +function mdSelectOnFocusDirective($timeout) { return { restrict: 'A', @@ -626,15 +628,40 @@ function mdSelectOnFocusDirective() { function postLink(scope, element, attr) { if (element[0].nodeName !== 'INPUT' && element[0].nodeName !== "TEXTAREA") return; - element.on('focus', onFocus); + var preventMouseUp = false; + + element + .on('focus', onFocus) + .on('mouseup', onMouseUp); scope.$on('$destroy', function() { - element.off('focus', onFocus); + element + .off('focus', onFocus) + .off('mouseup', onMouseUp); }); function onFocus() { - // Use HTMLInputElement#select to fix firefox select issues - element[0].select(); + preventMouseUp = true; + + $timeout(function() { + // Use HTMLInputElement#select to fix firefox select issues. + // The debounce is here for Edge's sake, otherwise the selection doesn't work. + element[0].select(); + + // This should be reset from inside the `focus`, because the event might + // have originated from something different than a click, e.g. a keyboard event. + preventMouseUp = false; + }, 1, false); + } + + // Prevents the default action of the first `mouseup` after a focus. + // This is necessary, because browsers fire a `mouseup` right after the element + // has been focused. In some browsers (Firefox in particular) this can clear the + // selection. There are examples of the problem in issue #7487. + function onMouseUp(event) { + if (preventMouseUp) { + event.preventDefault(); + } } } } @@ -706,7 +733,7 @@ function mdInputInvalidMessagesAnimation($q, $animateCss) { } // NOTE: We do not need the removeClass method, because the message ng-leave animation will fire - } + }; } function ngMessagesAnimation($q, $animateCss) { diff --git a/src/components/input/input.scss b/src/components/input/input.scss index 3d54ff7e749..b0944c1c2f5 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -82,11 +82,21 @@ md-input-container { textarea { resize: none; overflow: hidden; - } - textarea.md-input { - min-height: $input-line-height; - -ms-flex-preferred-size: auto; //IE fix + &.md-input { + min-height: $input-line-height; + -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; + } } label:not(._md-container-ignore) { diff --git a/src/components/input/input.spec.js b/src/components/input/input.spec.js index d546a8b2b5f..d55bded13f0 100644 --- a/src/components/input/input.spec.js +++ b/src/components/input/input.spec.js @@ -422,7 +422,7 @@ describe('md-input-container directive', function() { expect(el[0].querySelector("[ng-messages]").classList.contains('md-auto-hide')).toBe(false); })); - it('should select the input value on focus', inject(function() { + it('should select the input value on focus', inject(function($timeout) { var container = setup('md-select-on-focus'); var input = container.find('input'); input.val('Auto Text Select'); @@ -438,7 +438,9 @@ describe('md-input-container directive', function() { document.body.removeChild(container[0]); function isTextSelected(input) { - return input.selectionStart == 0 && input.selectionEnd == input.value.length + // The selection happens in a timeout which needs to be flushed. + $timeout.flush(); + return input.selectionStart === 0 && input.selectionEnd == input.value.length; } })); @@ -511,6 +513,22 @@ describe('md-input-container directive', function() { 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'; + + createAndAppendElement('rows="2"'); + + ngTextarea.val('Single line of text'); + ngTextarea.triggerHandler('input'); + + expect(ngTextarea.hasClass(scrollableClass)).toBe(false); + + ngTextarea.val('Multiple\nlines\nof\ntext'); + ngTextarea.triggerHandler('input'); + + expect(ngTextarea.hasClass(scrollableClass)).toBe(true); + }); }); describe('icons', function () {