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

Commit

Permalink
feat(textarea): textarea shrinking and resizing
Browse files Browse the repository at this point in the history
* 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.

Closes #7649.

BREAKING CHANGE
This changes the behavior from considering the "rows" attribute as the maximum to considering it as the minimum.
  • Loading branch information
crisbeto committed Apr 15, 2016
1 parent 9245f54 commit 86f7586
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 72 deletions.
6 changes: 5 additions & 1 deletion src/components/input/input-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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}}';
}
Expand Down
192 changes: 141 additions & 51 deletions src/components/input/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ function labelDirective() {
* @param {string=} placeholder An alternative approach to using aria-label when the label is not
* 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-resize {boolean=} Disables the textarea resize handle.
* @param md-no-asterisk {boolean=} When present, asterisk will not be appended to required inputs label
* @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.
Expand Down Expand Up @@ -255,9 +257,21 @@ function labelDirective() {
* error animation effects. Therefore, it is *not* advised to use the Layout system inside of the
* `<md-input-container>` tags. Instead, use relative or absolute positioning.
*
*
* <h3>Textarea directive</h3>
* 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'],
Expand Down Expand Up @@ -375,13 +389,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];

Expand All @@ -391,78 +408,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('<div class="md-resize-handle"></div>');
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.
Expand Down
21 changes: 15 additions & 6 deletions src/components/input/input.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
Expand Down
Loading

0 comments on commit 86f7586

Please sign in to comment.