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

Commit ce07651

Browse files
crisbetoThomasBurleson
authored andcommitted
feat(textarea): support 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. * Adds the option to specify a maximum number of rows for a textarea via the `max-rows` attribute. BREAKING CHANGE This changes the behavior from considering the "rows" attribute as the maximum to considering it as the minimum. Fixes #7649. Fixes #5919. Fixes #8135. Closes #7991
1 parent 560474f commit ce07651

File tree

4 files changed

+230
-72
lines changed

4 files changed

+230
-72
lines changed

src/components/input/input-theme.scss

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,14 @@ md-input-container.md-THEME_NAME-theme {
3535
color: '{{foreground-2}}';
3636
}
3737
}
38-
&.md-input-focused {
38+
&.md-input-focused,
39+
&.md-input-resized {
3940
.md-input {
4041
border-color: '{{primary-color}}';
4142
}
43+
}
44+
45+
&.md-input-focused {
4246
label {
4347
color: '{{primary-color}}';
4448
}

src/components/input/input.js

Lines changed: 141 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ function labelDirective() {
174174
* PRESENT. The placeholder text is copied to the aria-label attribute.
175175
* @param md-no-autogrow {boolean=} When present, textareas will not grow automatically.
176176
* @param md-no-asterisk {boolean=} When present, an asterisk will not be appended to the inputs floating label
177+
* @param md-no-resize {boolean=} Disables the textarea resize handle.
178+
* @param {number=} max-rows The maximum amount of rows for a textarea.
177179
* @param md-detect-hidden {boolean=} When present, textareas will be sized properly when they are
178180
* revealed after being hidden. This is off by default for performance reasons because it
179181
* guarantees a reflow every digest cycle.
@@ -259,9 +261,21 @@ function labelDirective() {
259261
* error animation effects. Therefore, it is *not* advised to use the Layout system inside of the
260262
* `<md-input-container>` tags. Instead, use relative or absolute positioning.
261263
*
264+
*
265+
* <h3>Textarea directive</h3>
266+
* The `textarea` element within a `md-input-container` has the following specific behavior:
267+
* - By default the `textarea` grows as the user types. This can be disabled via the `md-no-autogrow`
268+
* attribute.
269+
* - If a `textarea` has the `rows` attribute, it will treat the `rows` as the minimum height and will
270+
* continue growing as the user types. For example a textarea with `rows="3"` will be 3 lines of text
271+
* high initially. If no rows are specified, the directive defaults to 1.
272+
* - If you wan't a `textarea` to stop growing at a certain point, you can specify the `max-rows` attribute.
273+
* - The textarea's bottom border acts as a handle which users can drag, in order to resize the element vertically.
274+
* Once the user has resized a `textarea`, the autogrowing functionality becomes disabled. If you don't want a
275+
* `textarea` to be resizeable by the user, you can add the `md-no-resize` attribute.
262276
*/
263277

264-
function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
278+
function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout, $mdGesture) {
265279
return {
266280
restrict: 'E',
267281
require: ['^?mdInputContainer', '?ngModel'],
@@ -379,13 +393,16 @@ function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
379393
}
380394

381395
function setupTextarea() {
382-
if (attr.hasOwnProperty('mdNoAutogrow')) {
383-
return;
384-
}
396+
var isAutogrowing = !attr.hasOwnProperty('mdNoAutogrow');
397+
398+
attachResizeHandle();
399+
400+
if (!isAutogrowing) return;
385401

386402
// Can't check if height was or not explicity set,
387403
// so rows attribute will take precedence if present
388404
var minRows = attr.hasOwnProperty('rows') ? parseInt(attr.rows) : NaN;
405+
var maxRows = attr.hasOwnProperty('maxRows') ? parseInt(attr.maxRows) : NaN;
389406
var lineHeight = null;
390407
var node = element[0];
391408

@@ -395,78 +412,151 @@ function inputTextareaDirective($mdUtil, $window, $mdAria, $timeout) {
395412
$mdUtil.nextTick(growTextarea);
396413
}, 10, false);
397414

398-
// We can hook into Angular's pipeline, instead of registering a new listener.
399-
// Note that we should use `$parsers`, as opposed to `$viewChangeListeners` which
400-
// was used before, because `$viewChangeListeners` don't fire if the input is
401-
// invalid.
415+
// We could leverage ngModel's $parsers here, however it
416+
// isn't reliable, because Angular trims the input by default,
417+
// which means that growTextarea won't fire when newlines and
418+
// spaces are added.
419+
element.on('input', growTextarea);
420+
421+
// We should still use the $formatters, because they fire when
422+
// the value was changed from outside the textarea.
402423
if (hasNgModel) {
403-
ngModelCtrl.$formatters.unshift(pipelineListener);
404-
ngModelCtrl.$parsers.unshift(pipelineListener);
405-
} else {
406-
// Note that it's safe to use the `input` event since we're not supporting IE9 and below.
407-
element.on('input', growTextarea);
424+
ngModelCtrl.$formatters.push(formattersListener);
408425
}
409426

410427
if (!minRows) {
411-
element
412-
.attr('rows', 1)
413-
.on('scroll', onScroll);
428+
element.attr('rows', 1);
414429
}
415430

416431
angular.element($window).on('resize', growTextarea);
417-
418-
scope.$on('$destroy', function() {
419-
angular.element($window).off('resize', growTextarea);
420-
});
432+
scope.$on('$destroy', disableAutogrow);
421433

422434
function growTextarea() {
423435
// temporarily disables element's flex so its height 'runs free'
424436
element
425-
.addClass('md-no-flex')
426-
.attr('rows', 1);
427-
428-
if (minRows) {
429-
if (!lineHeight) {
430-
node.style.minHeight = 0;
431-
lineHeight = element.prop('clientHeight');
432-
node.style.minHeight = null;
433-
}
437+
.attr('rows', 1)
438+
.css('height', 'auto')
439+
.addClass('md-no-flex');
434440

435-
var newRows = Math.round( Math.round(getHeight() / lineHeight) );
436-
var rowsToSet = Math.min(newRows, minRows);
441+
var height = getHeight();
437442

438-
element
439-
.css('height', lineHeight * rowsToSet + 'px')
440-
.attr('rows', rowsToSet)
441-
.toggleClass('_md-textarea-scrollable', newRows >= minRows);
443+
if (!lineHeight) {
444+
// offsetHeight includes padding which can throw off our value
445+
lineHeight = element.css('padding', 0).prop('offsetHeight');
446+
element.css('padding', null);
447+
}
442448

443-
} else {
444-
element.css('height', 'auto');
445-
node.scrollTop = 0;
446-
var height = getHeight();
447-
if (height) element.css('height', height + 'px');
449+
if (minRows && lineHeight) {
450+
height = Math.max(height, lineHeight * minRows);
451+
}
452+
453+
if (maxRows && lineHeight) {
454+
var maxHeight = lineHeight * maxRows;
455+
456+
if (maxHeight < height) {
457+
element.attr('md-no-autogrow', '');
458+
height = maxHeight;
459+
} else {
460+
element.removeAttr('md-no-autogrow');
461+
}
462+
}
463+
464+
if (lineHeight) {
465+
element.attr('rows', Math.round(height / lineHeight));
448466
}
449467

450-
element.removeClass('md-no-flex');
468+
element
469+
.css('height', height + 'px')
470+
.removeClass('md-no-flex');
451471
}
452472

453473
function getHeight() {
454474
var offsetHeight = node.offsetHeight;
455475
var line = node.scrollHeight - offsetHeight;
456-
return offsetHeight + (line > 0 ? line : 0);
476+
return offsetHeight + Math.max(line, 0);
457477
}
458478

459-
function onScroll(e) {
460-
node.scrollTop = 0;
461-
// for smooth new line adding
462-
var line = node.scrollHeight - node.offsetHeight;
463-
var height = node.offsetHeight + line;
464-
node.style.height = height + 'px';
479+
function formattersListener(value) {
480+
$mdUtil.nextTick(growTextarea);
481+
return value;
465482
}
466483

467-
function pipelineListener(value) {
468-
growTextarea();
469-
return value;
484+
function disableAutogrow() {
485+
if (!isAutogrowing) return;
486+
487+
isAutogrowing = false;
488+
angular.element($window).off('resize', growTextarea);
489+
element
490+
.attr('md-no-autogrow', '')
491+
.off('input', growTextarea);
492+
493+
if (hasNgModel) {
494+
var listenerIndex = ngModelCtrl.$formatters.indexOf(formattersListener);
495+
496+
if (listenerIndex > -1) {
497+
ngModelCtrl.$formatters.splice(listenerIndex, 1);
498+
}
499+
}
500+
}
501+
502+
function attachResizeHandle() {
503+
if (attr.hasOwnProperty('mdNoResize')) return;
504+
505+
var handle = angular.element('<div class="md-resize-handle"></div>');
506+
var isDragging = false;
507+
var dragStart = null;
508+
var startHeight = 0;
509+
var container = containerCtrl.element;
510+
var dragGestureHandler = $mdGesture.register(handle, 'drag', { horizontal: false });
511+
512+
element.after(handle);
513+
handle.on('mousedown', onMouseDown);
514+
515+
container
516+
.on('$md.dragstart', onDragStart)
517+
.on('$md.drag', onDrag)
518+
.on('$md.dragend', onDragEnd);
519+
520+
scope.$on('$destroy', function() {
521+
handle
522+
.off('mousedown', onMouseDown)
523+
.remove();
524+
525+
container
526+
.off('$md.dragstart', onDragStart)
527+
.off('$md.drag', onDrag)
528+
.off('$md.dragend', onDragEnd);
529+
530+
dragGestureHandler();
531+
handle = null;
532+
container = null;
533+
dragGestureHandler = null;
534+
});
535+
536+
function onMouseDown(ev) {
537+
ev.preventDefault();
538+
isDragging = true;
539+
dragStart = ev.clientY;
540+
startHeight = parseFloat(element.css('height')) || element.prop('offsetHeight');
541+
}
542+
543+
function onDragStart(ev) {
544+
if (!isDragging) return;
545+
ev.preventDefault();
546+
disableAutogrow();
547+
container.addClass('md-input-resized');
548+
}
549+
550+
function onDrag(ev) {
551+
if (!isDragging) return;
552+
element.css('height', startHeight + (ev.pointer.y - dragStart) + 'px');
553+
}
554+
555+
function onDragEnd(ev) {
556+
if (!isDragging) return;
557+
isDragging = false;
558+
container.removeClass('md-input-resized');
559+
}
470560
}
471561

472562
// Attach a watcher to detect when the textarea gets shown.

src/components/input/input.scss

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ $icon-top-offset: ($icon-offset - $input-padding-top - $input-border-width-focus
2424

2525
$icon-float-focused-top: -8px !default;
2626

27+
$input-resize-handle-height: 10px !default;
28+
2729
md-input-container {
2830
@include pie-clearfix();
2931
display: inline-block;
@@ -46,6 +48,16 @@ md-input-container {
4648
min-width: 1px;
4749
}
4850

51+
.md-resize-handle {
52+
position: absolute;
53+
bottom: $input-error-height - $input-border-width-default * 2;
54+
left: 0;
55+
height: $input-resize-handle-height;
56+
background: transparent;
57+
width: 100%;
58+
cursor: ns-resize;
59+
}
60+
4961
> md-icon {
5062
position: absolute;
5163
top: $icon-top-offset;
@@ -88,14 +100,10 @@ md-input-container {
88100
-ms-flex-preferred-size: auto; //IE fix
89101
}
90102

91-
&._md-textarea-scrollable,
92-
&[md-no-autogrow] {
93-
overflow: auto;
94-
}
95-
96103
// The height usually gets set to 1 line by `.md-input`.
97104
&[md-no-autogrow] {
98105
height: auto;
106+
overflow: auto;
99107
}
100108
}
101109

@@ -299,7 +307,8 @@ md-input-container {
299307

300308
// Use wide border in error state or in focused state
301309
&.md-input-focused .md-input,
302-
.md-input.ng-invalid.ng-dirty {
310+
.md-input.ng-invalid.ng-dirty,
311+
&.md-input-resized .md-input {
303312
padding-bottom: 0; // Increase border width by 1px, decrease padding by 1
304313
border-width: 0 0 $input-border-width-focused 0;
305314
}

0 commit comments

Comments
 (0)