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

Commit 7789d6a

Browse files
crisbetoThomasBurleson
authored andcommitted
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`. Fixes #7487. Closes #7553
1 parent 051474e commit 7789d6a

File tree

4 files changed

+120
-65
lines changed

4 files changed

+120
-65
lines changed

src/components/input/demoBasicUsage/script.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ angular
1818
'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 ' +
1919
'WY').split(' ').map(function(state) {
2020
return {abbrev: state};
21-
})
21+
});
2222
})
2323
.config(function($mdThemingProvider) {
2424

src/components/input/input.js

Lines changed: 85 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ function labelDirective() {
250250
*
251251
*/
252252

253-
function inputTextareaDirective($mdUtil, $window, $mdAria) {
253+
function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
254254
return {
255255
restrict: 'E',
256256
require: ['^?mdInputContainer', '?ngModel'],
@@ -365,84 +365,81 @@ function inputTextareaDirective($mdUtil, $window, $mdAria) {
365365
}
366366

367367
function setupTextarea() {
368-
if (angular.isDefined(element.attr('md-no-autogrow'))) {
368+
if (attr.hasOwnProperty('mdNoAutogrow')) {
369369
return;
370370
}
371371

372-
var node = element[0];
373-
var container = containerCtrl.element[0];
374-
375-
var min_rows = NaN;
376-
var lineHeight = null;
377-
// can't check if height was or not explicity set,
372+
// Can't check if height was or not explicity set,
378373
// so rows attribute will take precedence if present
379-
if (node.hasAttribute('rows')) {
380-
min_rows = parseInt(node.getAttribute('rows'));
381-
}
382-
383-
var onChangeTextarea = $mdUtil.debounce(growTextarea, 1);
384-
385-
function pipelineListener(value) {
386-
onChangeTextarea();
387-
return value;
388-
}
374+
var minRows = attr.hasOwnProperty('rows') ? parseInt(attr.rows) : NaN;
375+
var lineHeight = null;
376+
var node = element[0];
389377

390-
if (ngModelCtrl) {
391-
ngModelCtrl.$formatters.push(pipelineListener);
392-
ngModelCtrl.$viewChangeListeners.push(pipelineListener);
378+
// This timeout is necessary, because the browser needs a little bit
379+
// of time to calculate the `clientHeight` and `scrollHeight`.
380+
$timeout(function() {
381+
$mdUtil.nextTick(growTextarea);
382+
}, 10, false);
383+
384+
// We can hook into Angular's pipeline, instead of registering a new listener.
385+
// Note that we should use `$parsers`, as opposed to `$viewChangeListeners` which
386+
// was used before, because `$viewChangeListeners` don't fire if the input is
387+
// invalid.
388+
if (hasNgModel) {
389+
ngModelCtrl.$formatters.unshift(pipelineListener);
390+
ngModelCtrl.$parsers.unshift(pipelineListener);
393391
} else {
394-
onChangeTextarea();
392+
// Note that it's safe to use the `input` event since we're not supporting IE9 and below.
393+
element.on('input', growTextarea);
395394
}
396-
element.on('keydown input', onChangeTextarea);
397-
398-
if (isNaN(min_rows)) {
399-
element.attr('rows', '1');
400395

401-
element.on('scroll', onScroll);
396+
if (!minRows) {
397+
element
398+
.attr('rows', 1)
399+
.on('scroll', onScroll);
402400
}
403401

404-
angular.element($window).on('resize', onChangeTextarea);
402+
angular.element($window).on('resize', growTextarea);
405403

406404
scope.$on('$destroy', function() {
407-
angular.element($window).off('resize', onChangeTextarea);
405+
angular.element($window).off('resize', growTextarea);
408406
});
409407

410408
function growTextarea() {
411-
// sets the md-input-container height to avoid jumping around
412-
container.style.height = container.offsetHeight + 'px';
413-
414409
// temporarily disables element's flex so its height 'runs free'
415-
element.addClass('md-no-flex');
416-
417-
if (isNaN(min_rows)) {
418-
node.style.height = "auto";
419-
node.scrollTop = 0;
420-
var height = getHeight();
421-
if (height) node.style.height = height + 'px';
422-
} else {
423-
node.setAttribute("rows", 1);
410+
element
411+
.addClass('md-no-flex')
412+
.attr('rows', 1);
424413

414+
if (minRows) {
425415
if (!lineHeight) {
426-
node.style.minHeight = '0';
427-
416+
node.style.minHeight = 0;
428417
lineHeight = element.prop('clientHeight');
429-
430418
node.style.minHeight = null;
431419
}
432420

433-
var rows = Math.min(min_rows, Math.round(node.scrollHeight / lineHeight));
434-
node.setAttribute("rows", rows);
435-
node.style.height = lineHeight * rows + "px";
421+
var newRows = Math.round( Math.round(getHeight() / lineHeight) );
422+
var rowsToSet = Math.min(newRows, minRows);
423+
424+
element
425+
.css('height', lineHeight * rowsToSet + 'px')
426+
.attr('rows', rowsToSet)
427+
.toggleClass('_md-textarea-scrollable', newRows >= minRows);
428+
429+
} else {
430+
element.css('height', 'auto');
431+
node.scrollTop = 0;
432+
var height = getHeight();
433+
if (height) element.css('height', height + 'px');
436434
}
437435

438-
// reset everything back to normal
439436
element.removeClass('md-no-flex');
440-
container.style.height = 'auto';
441437
}
442438

443439
function getHeight() {
444-
var line = node.scrollHeight - node.offsetHeight;
445-
return node.offsetHeight + (line > 0 ? line : 0);
440+
var offsetHeight = node.offsetHeight;
441+
var line = node.scrollHeight - offsetHeight;
442+
return offsetHeight + (line > 0 ? line : 0);
446443
}
447444

448445
function onScroll(e) {
@@ -453,8 +450,13 @@ function inputTextareaDirective($mdUtil, $window, $mdAria) {
453450
node.style.height = height + 'px';
454451
}
455452

453+
function pipelineListener(value) {
454+
growTextarea();
455+
return value;
456+
}
457+
456458
// Attach a watcher to detect when the textarea gets shown.
457-
if (angular.isDefined(element.attr('md-detect-hidden'))) {
459+
if (attr.hasOwnProperty('mdDetectHidden')) {
458460

459461
var handleHiddenChange = function() {
460462
var wasHidden = false;
@@ -616,7 +618,7 @@ function placeholderDirective($log) {
616618
*
617619
* </hljs>
618620
*/
619-
function mdSelectOnFocusDirective() {
621+
function mdSelectOnFocusDirective($timeout) {
620622

621623
return {
622624
restrict: 'A',
@@ -626,15 +628,40 @@ function mdSelectOnFocusDirective() {
626628
function postLink(scope, element, attr) {
627629
if (element[0].nodeName !== 'INPUT' && element[0].nodeName !== "TEXTAREA") return;
628630

629-
element.on('focus', onFocus);
631+
var preventMouseUp = false;
632+
633+
element
634+
.on('focus', onFocus)
635+
.on('mouseup', onMouseUp);
630636

631637
scope.$on('$destroy', function() {
632-
element.off('focus', onFocus);
638+
element
639+
.off('focus', onFocus)
640+
.off('mouseup', onMouseUp);
633641
});
634642

635643
function onFocus() {
636-
// Use HTMLInputElement#select to fix firefox select issues
637-
element[0].select();
644+
preventMouseUp = true;
645+
646+
$timeout(function() {
647+
// Use HTMLInputElement#select to fix firefox select issues.
648+
// The debounce is here for Edge's sake, otherwise the selection doesn't work.
649+
element[0].select();
650+
651+
// This should be reset from inside the `focus`, because the event might
652+
// have originated from something different than a click, e.g. a keyboard event.
653+
preventMouseUp = false;
654+
}, 1, false);
655+
}
656+
657+
// Prevents the default action of the first `mouseup` after a focus.
658+
// This is necessary, because browsers fire a `mouseup` right after the element
659+
// has been focused. In some browsers (Firefox in particular) this can clear the
660+
// selection. There are examples of the problem in issue #7487.
661+
function onMouseUp(event) {
662+
if (preventMouseUp) {
663+
event.preventDefault();
664+
}
638665
}
639666
}
640667
}
@@ -706,7 +733,7 @@ function mdInputInvalidMessagesAnimation($q, $animateCss) {
706733
}
707734

708735
// NOTE: We do not need the removeClass method, because the message ng-leave animation will fire
709-
}
736+
};
710737
}
711738

712739
function ngMessagesAnimation($q, $animateCss) {

src/components/input/input.scss

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,21 @@ md-input-container {
8282
textarea {
8383
resize: none;
8484
overflow: hidden;
85-
}
8685

87-
textarea.md-input {
88-
min-height: $input-line-height;
89-
-ms-flex-preferred-size: auto; //IE fix
86+
&.md-input {
87+
min-height: $input-line-height;
88+
-ms-flex-preferred-size: auto; //IE fix
89+
}
90+
91+
&._md-textarea-scrollable,
92+
&[md-no-autogrow] {
93+
overflow: auto;
94+
}
95+
96+
// The height usually gets set to 1 line by `.md-input`.
97+
&[md-no-autogrow] {
98+
height: auto;
99+
}
90100
}
91101

92102
label:not(._md-container-ignore) {

src/components/input/input.spec.js

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ describe('md-input-container directive', function() {
422422
expect(el[0].querySelector("[ng-messages]").classList.contains('md-auto-hide')).toBe(false);
423423
}));
424424

425-
it('should select the input value on focus', inject(function() {
425+
it('should select the input value on focus', inject(function($timeout) {
426426
var container = setup('md-select-on-focus');
427427
var input = container.find('input');
428428
input.val('Auto Text Select');
@@ -438,7 +438,9 @@ describe('md-input-container directive', function() {
438438
document.body.removeChild(container[0]);
439439

440440
function isTextSelected(input) {
441-
return input.selectionStart == 0 && input.selectionEnd == input.value.length
441+
// The selection happens in a timeout which needs to be flushed.
442+
$timeout.flush();
443+
return input.selectionStart === 0 && input.selectionEnd == input.value.length;
442444
}
443445
}));
444446

@@ -511,6 +513,22 @@ describe('md-input-container directive', function() {
511513
var newHeight = textarea.offsetHeight;
512514
expect(textarea.offsetHeight).toBeGreaterThan(oldHeight);
513515
});
516+
517+
it('should make the textarea scrollable once it has reached the row limit', function() {
518+
var scrollableClass = '_md-textarea-scrollable';
519+
520+
createAndAppendElement('rows="2"');
521+
522+
ngTextarea.val('Single line of text');
523+
ngTextarea.triggerHandler('input');
524+
525+
expect(ngTextarea.hasClass(scrollableClass)).toBe(false);
526+
527+
ngTextarea.val('Multiple\nlines\nof\ntext');
528+
ngTextarea.triggerHandler('input');
529+
530+
expect(ngTextarea.hasClass(scrollableClass)).toBe(true);
531+
});
514532
});
515533

516534
describe('icons', function () {

0 commit comments

Comments
 (0)