Google analytics script

Latest jQuery CDN with code tiggling.

Friday, 22 January 2016

Autogrow textarea Angular directive with vertically centered text

This post is about emulating input[text] with a textarea element to create a text wrapping input that adjusts its height to the amount of content it holds. A working example can be found on Plunker.

When was the last time you asked yourself about input[text] element's user experience and usability? Well in normal situation (that is likely 90% of the time) this element works great but in the remaining cases it may not be ideal. Whenever you need your users to enter long(er) one-liners input[text] may not be your best option as it can't wrap text so part of it outside element's boundaries is being hidden. If nothing else, it's distracting to users.

You mainly have two options here each with its ups and downs:

  • textarea element - very similar to input[text] as it allows entering unformatted text that can wrap as many lines as its length requires; it has some styling problems though (I'll explain that in a bit) and also allows entering multiple lines of text which may be undesirable but easy to handle/prevent
  • content editable div element - it can easily be styled to look exactly like input[text] and it also wraps long lines of text (normally) just like textarea; the main problem is controlling its behaviour to prevent text formatting in a cross browser way

Of the two options the first one seems simpler so let's implement it.

Why Angular directive?

Autogrow textarea can be implemented using vanilla Javascript per app, or as a reusable jQuery plugin or as an AngularJS directive as this implementation will show. Basic things work in the same way so if you need it as a jQuery plugin or in any other form it should be pretty easy to accomplish for the same. But since I need this in an Angular web application a directive will be. It will mainly do these things:

  • It will add autogrow behaviour to selected textareas
  • It will take care of vertical text alignment to the middle when textarea is higher than its content
  • I'll prevent entering and strip newlines from copy-pasted text

Selecting certain textareas

Every Angular directive can be restricted to one or a combination of

  • E - element name,
  • A - element attribute,
  • C - element CSS class and/or
  • M - HTML comment which is extremely seldom used
In our case where we're adding additional behaviour to textareas we'll be choosing element name restriction (E). But to select only certain textareas we could then be checking for specific CSS class or HTML attribute. Following implementation will be looking for either so any of these will work correctly
<!-- CSS class -->
<textarea class="autogrow" ng-model="something"></textarea>
<!-- HTML attribute -->
<textarea autogrow ng-model="something"></textarea>
<!-- both -->
<textarea class="autogrow" autogrow ng-model="something"></textarea>

CSS styling problem?

If we wanted to use textarea as a text-wrapping single line textbox (as input[text]) we need to vertically align text to the middle of it. But with textareas nothing seems to work in terms of CSS that would display text vertically in the middle. At least not in a dynamic manner according to amount of content inside of it. To actually accomplish this we need to measure the height of the content and add some padding to the top of the containing textarea to make text appear in the vertical middle. And that's exactly what this directive is doing.

Angular directive code

As we're creating an Angular directive for a certain type of a textarea elements it makes sense that we also require ng-model on it, because it is basically an input element and we should adjust its height and alignment even if bound model changes elsewhere and not just while we're typing it into the textarea element.

   1:  (function (angular) {
   2:   
   3:    angular
   4:      .module("YourModule")
   5:      .directive("textarea", function () {
   6:        return {
   7:          restrict: "AC",
   8:          require: "ngModel",
   9:          link: AutogrowDirectiveLink
  10:        };
  11:      });
  12:   
  13:    // #region Directive link definition
  14:   
  15:    function AutogrowDirectiveLink(scope, element, attributes) {
  16:      // textareas can be marked either by CSS class or HTML attribute
  17:      if (!element.hasClass("autogrow") && attributes.autogrow === undefined)
  18:      {
  19:        // no autogrow for this textarea
  20:        return;
  21:      }
  22:   
  23:      // get minimum CSS height value (if defined)
  24:      var minHeight = parseInt(window.getComputedStyle(element[0]).getPropertyValue("min-height")) || 0;
  25:   
  26:      // prevent newlines
  27:      element.on("keydown", function (evt) {
  28:        if (evt.which === 13)
  29:        {
  30:          evt.preventDefault();
  31:        }
  32:      });
  33:   
  34:      // fit height to content
  35:      element.on("input", function (evt) {
  36:        // strip newlines when something's been pasted into the element
  37:        if (this.value.indexOf("\n"))
  38:        {
  39:          this.value = this.value.replace(/\n+/gi, "");
  40:        }
  41:   
  42:        // minimize textarea size to determine content height
  43:        element.css({
  44:          paddingTop: 0,
  45:          height: 0,
  46:          minHeight: 0
  47:        });
  48:   
  49:        var contentHeight = this.scrollHeight;
  50:        var borderHeight = this.offsetHeight; // both top and bottom borders combined
  51:   
  52:        element.css({
  53:          paddingTop: ~~(Math.max(0, minHeight - contentHeight) / 2) + "px",
  54:          minHeight: "", // remove inline style reverting to default settings
  55:          height: contentHeight + borderHeight + "px" // add border because we're using border-box
  56:        });
  57:      });
  58:   
  59:      // watch model changes from the outside to adjust height
  60:      scope.$watch(attributes.ngModel, trigger);
  61:   
  62:      // set initial size
  63:      trigger();
  64:   
  65:      function trigger() {
  66:        setTimeout(element.triggerHandler.bind(element, "input"), 1);
  67:      }
  68:    }
  69:   
  70:    // #endregion
  71:   
  72:  })(angular);

Suggestions?

This directive could be enhanced to provide additional settings like enabling or disabling vertical middle alignment. I developed it in this way because I needed it like this, but if you require a different way you're free to extend it to your liking. Basics are all here.

1 comment:

  1. This comment has been removed by a blog administrator.

    ReplyDelete