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

Commit

Permalink
fix(textarea): scrolling, text selection, reduced DOM manipulation.
Browse files Browse the repository at this point in the history
* 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`.

Fixes #7487. Closes #7553
  • Loading branch information
crisbeto authored and ThomasBurleson committed Mar 14, 2016
1 parent 051474e commit 7789d6a
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 65 deletions.
2 changes: 1 addition & 1 deletion src/components/input/demoBasicUsage/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down
143 changes: 85 additions & 58 deletions src/components/input/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ function labelDirective() {
*
*/

function inputTextareaDirective($mdUtil, $window, $mdAria) {
function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
return {
restrict: 'E',
require: ['^?mdInputContainer', '?ngModel'],
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -616,7 +618,7 @@ function placeholderDirective($log) {
*
* </hljs>
*/
function mdSelectOnFocusDirective() {
function mdSelectOnFocusDirective($timeout) {

return {
restrict: 'A',
Expand All @@ -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();
}
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 14 additions & 4 deletions src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 20 additions & 2 deletions src/components/input/input.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
}
}));

Expand Down Expand Up @@ -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 () {
Expand Down

0 comments on commit 7789d6a

Please sign in to comment.