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

Commit

Permalink
feat(textarea): support 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.

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
  • Loading branch information
crisbeto authored and ThomasBurleson committed Jun 2, 2016
1 parent 560474f commit ce07651
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 @@ -174,6 +174,8 @@ function labelDirective() {
* 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-asterisk {boolean=} When present, an asterisk will not be appended to the inputs floating label
* @param md-no-resize {boolean=} Disables the textarea resize handle.
* @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 @@ -259,9 +261,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 @@ -379,13 +393,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 @@ -395,78 +412,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 ce07651

Please sign in to comment.