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

feat(textarea): textarea shrinking and resizing #7991

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haven't used it much; do we need to deregister the drag gesture above (i.e. undo the $mdGesture.register?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say so. I remember trying to find if there was a deregister method, but I couldn't find one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deregister function will be returned from the register call.

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