chromium/third_party/google-closure-library/closure/goog/ui/textarea.js

/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

/**
 * @fileoverview A content-aware textarea control that grows and shrinks
 * automatically. This implementation extends {@link goog.ui.Control}.
 * This code is inspired by Dojo Dijit's Textarea implementation with
 * modifications to support native (when available) textarea resizing and
 * minHeight and maxHeight enforcement.
 *
 * @see ../demos/textarea.html
 */

goog.provide('goog.ui.Textarea');
goog.provide('goog.ui.Textarea.EventType');

goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.dom.classlist');
goog.require('goog.events.EventType');
goog.require('goog.style');
goog.require('goog.ui.Control');
goog.require('goog.ui.TextareaRenderer');
goog.require('goog.userAgent');
goog.requireType('goog.events.BrowserEvent');
goog.requireType('goog.events.Event');
goog.requireType('goog.math.Box');



/**
 * A textarea control to handle growing/shrinking with textarea.value.
 *
 * @param {string} content Text to set as the textarea's value.
 * @param {goog.ui.TextareaRenderer=} opt_renderer Renderer used to render or
 *     decorate the textarea. Defaults to {@link goog.ui.TextareaRenderer}.
 * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper, used for
 *     document interaction.
 * @constructor
 * @extends {goog.ui.Control}
 */
goog.ui.Textarea = function(content, opt_renderer, opt_domHelper) {
  'use strict';
  goog.ui.Control.call(
      this, content, opt_renderer || goog.ui.TextareaRenderer.getInstance(),
      opt_domHelper);

  this.setHandleMouseEvents(false);
  this.setAllowTextSelection(true);
  this.hasUserInput_ = (content != '');
  if (!content) {
    this.setContentInternal('');
  }
};
goog.inherits(goog.ui.Textarea, goog.ui.Control);


/**
 * Some UAs will shrink the textarea automatically, some won't.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.NEEDS_HELP_SHRINKING_ =
    !(goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(11));


/**
 * True if the resizing function is executing, false otherwise.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.isResizing_ = false;


/**
 * Represents if we have focus on the textarea element, used only
 * to render the placeholder if we don't have native placeholder
 * support.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.hasFocusForPlaceholder_ = false;


/**
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.hasUserInput_ = false;


/**
 * The height of the textarea as last measured.
 * @type {number}
 * @private
 */
goog.ui.Textarea.prototype.height_ = 0;


/**
 * A maximum height for the textarea. When set to 0, the default, there is no
 * enforcement of this value during resize.
 * @type {number}
 * @private
 */
goog.ui.Textarea.prototype.maxHeight_ = 0;


/**
 * A minimum height for the textarea. When set to 0, the default, there is no
 * enforcement of this value during resize.
 * @type {number}
 * @private
 */
goog.ui.Textarea.prototype.minHeight_ = 0;


/**
 * Whether or not textarea rendering characteristics have been discovered.
 * Specifically we determine, at runtime:
 *    If the padding and border box is included in offsetHeight.
 *    @see {goog.ui.Textarea.prototype.needsPaddingBorderFix_}
 *    If the padding and border box is included in scrollHeight.
 *    @see {goog.ui.Textarea.prototype.scrollHeightIncludesPadding_} and
 *    @see {goog.ui.Textarea.prototype.scrollHeightIncludesBorder_}
 * TODO(user): See if we can determine goog.ui.Textarea.NEEDS_HELP_SHRINKING_.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.hasDiscoveredTextareaCharacteristics_ = false;


/**
 * If a user agent doesn't correctly support the box-sizing:border-box CSS
 * value then we'll need to adjust our height calculations.
 * @see {goog.ui.Textarea.prototype.discoverTextareaCharacteristics_}
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.needsPaddingBorderFix_ = false;


/**
 * Whether or not scrollHeight of a textarea includes the padding box.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.scrollHeightIncludesPadding_ = false;


/**
 * Whether or not scrollHeight of a textarea includes the border box.
 * @type {boolean}
 * @private
 */
goog.ui.Textarea.prototype.scrollHeightIncludesBorder_ = false;


/**
 * For storing the padding box size during enterDocument, to prevent possible
 * measurement differences that can happen after text zooming.
 * Note: runtime padding changes will cause problems with this.
 * @type {goog.math.Box}
 * @private
 */
goog.ui.Textarea.prototype.paddingBox_;


/**
 * For storing the border box size during enterDocument, to prevent possible
 * measurement differences that can happen after text zooming.
 * Note: runtime border width changes will cause problems with this.
 * @type {goog.math.Box}
 * @private
 */
goog.ui.Textarea.prototype.borderBox_;


/**
 * Default text content for the textarea when it is unchanged and unfocussed.
 * We use the placeholder attribute for all browsers that have support for
 * it (new in HTML5 for the following browsers:
 *
 *   Internet Explorer 10.0
 *   Firefox 4.0
 *   Opera 11.6
 *   Chrome 4.0
 *   Safari 5.0
 *
 * For older browsers, we save the placeholderText_ and set it as the element's
 * value and add the TEXTAREA_PLACEHOLDER_CLASS to indicate that it's a
 * placeholder string.
 * @type {string}
 * @private
 */
goog.ui.Textarea.prototype.placeholderText_ = '';


/**
 * Constants for event names.
 * @enum {string}
 */
goog.ui.Textarea.EventType = {
  RESIZE: 'resize'
};


/**
 * Sets the default text for the textarea.
 * @param {string} text The default text for the textarea.
 */
goog.ui.Textarea.prototype.setPlaceholder = function(text) {
  'use strict';
  this.placeholderText_ = text;
  if (this.getElement()) {
    this.restorePlaceholder_();
  }
};


/**
 * @return {number} The padding plus the border box height.
 * @private
 */
goog.ui.Textarea.prototype.getPaddingBorderBoxHeight_ = function() {
  'use strict';
  var paddingBorderBoxHeight = this.paddingBox_.top + this.paddingBox_.bottom +
      this.borderBox_.top + this.borderBox_.bottom;
  return paddingBorderBoxHeight;
};


/**
 * @return {number} The minHeight value.
 */
goog.ui.Textarea.prototype.getMinHeight = function() {
  'use strict';
  return this.minHeight_;
};


/**
 * @return {number} The minHeight value with a potential padding fix.
 * @private
 */
goog.ui.Textarea.prototype.getMinHeight_ = function() {
  'use strict';
  var minHeight = this.minHeight_;
  var textarea = this.getElement();
  if (minHeight && textarea && this.needsPaddingBorderFix_) {
    minHeight -= this.getPaddingBorderBoxHeight_();
  }
  return minHeight;
};


/**
 * Sets a minimum height for the textarea, and calls resize if rendered.
 * @param {number} height New minHeight value.
 */
goog.ui.Textarea.prototype.setMinHeight = function(height) {
  'use strict';
  this.minHeight_ = height;
  this.resize();
};


/**
 * @return {number} The maxHeight value.
 */
goog.ui.Textarea.prototype.getMaxHeight = function() {
  'use strict';
  return this.maxHeight_;
};


/**
 * @return {number} The maxHeight value with a potential padding fix.
 * @private
 */
goog.ui.Textarea.prototype.getMaxHeight_ = function() {
  'use strict';
  var maxHeight = this.maxHeight_;
  var textarea = this.getElement();
  if (maxHeight && textarea && this.needsPaddingBorderFix_) {
    maxHeight -= this.getPaddingBorderBoxHeight_();
  }
  return maxHeight;
};


/**
 * Sets a maximum height for the textarea, and calls resize if rendered.
 * @param {number} height New maxHeight value.
 */
goog.ui.Textarea.prototype.setMaxHeight = function(height) {
  'use strict';
  this.maxHeight_ = height;
  this.resize();
};


/**
 * Sets the textarea's value.
 * @param {*} value The value property for the textarea, will be cast to a
 *     string by the browser when setting textarea.value.
 */
goog.ui.Textarea.prototype.setValue = function(value) {
  'use strict';
  this.setContent(String(value));
};


/**
 * Gets the textarea's value.
 * @return {string} value The value of the textarea.
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.getValue = function() {
  'use strict';
  // We potentially have the placeholder stored in the value.
  // If a client of this class sets this.getElement().value directly
  // we don't set the this.hasUserInput_ boolean. Thus, we need to
  // explicitly check if the value != the placeholder text. This has
  // the unfortunate edge case of:
  //   If the client sets this.getElement().value to the placeholder
  //   text, we'll return the empty string.
  // The normal use case shouldn't be an issue, however, since the
  // default placeholderText is the empty string. Also, if the end user
  // inputs text, then this.hasUserInput_ will always be true.
  if (this.getElement().value != this.placeholderText_ ||
      this.supportsNativePlaceholder_() || this.hasUserInput_) {
    // We don't do anything fancy here.
    return this.getElement().value;
  }
  return '';
};


/** @override */
goog.ui.Textarea.prototype.setContent = function(content) {
  'use strict';
  goog.ui.Textarea.superClass_.setContent.call(this, content);
  this.hasUserInput_ = (content != '');
  this.resize();
};


/**
 * @override *
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.setEnabled = function(enable) {
  'use strict';
  goog.ui.Textarea.superClass_.setEnabled.call(this, enable);
  this.getElement().disabled = !enable;
};


/**
 * Resizes the textarea vertically.
 */
goog.ui.Textarea.prototype.resize = function() {
  'use strict';
  if (this.getElement()) {
    this.grow_();
  }
};


/**
 * @return {boolean} True if the element supports the placeholder attribute.
 * @private
 */
goog.ui.Textarea.prototype.supportsNativePlaceholder_ = function() {
  'use strict';
  goog.asserts.assert(this.getElement());
  return 'placeholder' in this.getElement();
};


/**
 * Sets the value of the textarea element to the default text.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.restorePlaceholder_ = function() {
  'use strict';
  if (!this.placeholderText_) {
    // Return early if there is no placeholder to mess with.
    return;
  }
  // Check again in case something changed since this was scheduled.
  // We check that the element is still there since this is called by a timer
  // and the dispose method may have been called prior to this.
  if (this.supportsNativePlaceholder_()) {
    this.getElement().placeholder = this.placeholderText_;
  } else if (
      this.getElement() && !this.hasUserInput_ &&
      !this.hasFocusForPlaceholder_) {
    // We only want to set the value + placeholder CSS if we actually have
    // some placeholder text to show.
    goog.dom.classlist.add(
        goog.asserts.assert(this.getElement()),
        goog.ui.Textarea.TEXTAREA_PLACEHOLDER_CLASS);
    this.getElement().value = this.placeholderText_;
  }
};


/** @override **/
goog.ui.Textarea.prototype.enterDocument = function() {
  'use strict';
  goog.ui.Textarea.base(this, 'enterDocument');
  var textarea = this.getElement();

  // Eliminates the vertical scrollbar and changes the box-sizing mode for the
  // textarea to the border-box (aka quirksmode) paradigm.
  goog.style.setStyle(textarea, {
    'overflowY': 'hidden',
    'overflowX': 'auto',
    'boxSizing': 'border-box',
    'MsBoxSizing': 'border-box',
    'WebkitBoxSizing': 'border-box',
    'MozBoxSizing': 'border-box'
  });

  this.paddingBox_ = goog.style.getPaddingBox(textarea);
  this.borderBox_ = goog.style.getBorderBox(textarea);

  this.getHandler()
      .listen(textarea, goog.events.EventType.SCROLL, this.grow_)
      .listen(textarea, goog.events.EventType.FOCUS, this.grow_)
      .listen(textarea, goog.events.EventType.KEYUP, this.grow_)
      .listen(textarea, goog.events.EventType.MOUSEUP, this.mouseUpListener_)
      .listen(textarea, goog.events.EventType.BLUR, this.blur_);

  this.restorePlaceholder_();
  this.resize();
};


/**
 * Gets the textarea's content height + padding height + border height.
 * This is done by getting the scrollHeight and adjusting from there.
 * In the end this result is what we want the new offsetHeight to equal.
 * @return {number} The height of the textarea.
 * @private
 */
goog.ui.Textarea.prototype.getHeight_ = function() {
  'use strict';
  this.discoverTextareaCharacteristics_();
  var textarea = this.getElement();
  // Because enterDocument can be called even when the component is rendered
  // without being in a document, we may not have cached the correct paddingBox
  // data on render(). We try to make up for this here.
  if (isNaN(this.paddingBox_.top)) {
    this.paddingBox_ = goog.style.getPaddingBox(textarea);
    this.borderBox_ = goog.style.getBorderBox(textarea);
  }
  // Accounts for a possible (though unlikely) horizontal scrollbar.
  var height =
      this.getElement().scrollHeight + this.getHorizontalScrollBarHeight_();
  if (this.needsPaddingBorderFix_) {
    height -= this.getPaddingBorderBoxHeight_();
  } else {
    if (!this.scrollHeightIncludesPadding_) {
      var paddingBox = this.paddingBox_;
      var paddingBoxHeight = paddingBox.top + paddingBox.bottom;
      height += paddingBoxHeight;
    }
    if (!this.scrollHeightIncludesBorder_) {
      var borderBox = goog.style.getBorderBox(textarea);
      var borderBoxHeight = borderBox.top + borderBox.bottom;
      height += borderBoxHeight;
    }
  }
  return height;
};


/**
 * Sets the textarea's height.
 * @param {number} height The height to set.
 * @private
 */
goog.ui.Textarea.prototype.setHeight_ = function(height) {
  'use strict';
  if (this.height_ != height) {
    this.height_ = height;
    this.getElement().style.height = height + 'px';
  }
};


/**
 * Sets the textarea's rows attribute to be the number of newlines + 1.
 * This is necessary when the textarea is hidden, in which case scrollHeight
 * is not available.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.setHeightToEstimate_ = function() {
  'use strict';
  var textarea = this.getElement();
  textarea.style.height = 'auto';
  var newlines = textarea.value.match(/\n/g) || [];
  textarea.rows = newlines.length + 1;
  this.height_ = 0;
};


/**
 * Gets the height of (possibly present) horizontal scrollbar.
 * @return {number} The height of the horizontal scrollbar.
 * @private
 */
goog.ui.Textarea.prototype.getHorizontalScrollBarHeight_ = function() {
  'use strict';
  var textarea = /** @type {!HTMLElement} */ (this.getElement());
  var height = textarea.offsetHeight - textarea.clientHeight;
  if (!this.scrollHeightIncludesPadding_) {
    var paddingBox = this.paddingBox_;
    var paddingBoxHeight = paddingBox.top + paddingBox.bottom;
    height -= paddingBoxHeight;
  }
  if (!this.scrollHeightIncludesBorder_) {
    var borderBox = goog.style.getBorderBox(textarea);
    var borderBoxHeight = borderBox.top + borderBox.bottom;
    height -= borderBoxHeight;
  }
  // Prevent negative number results, which sometimes show up.
  return height > 0 ? height : 0;
};


/**
 * In order to assess the correct height for a textarea, we need to know
 * whether the scrollHeight (the full height of the text) property includes
 * the values for padding and borders. We can also test whether the
 * box-sizing: border-box setting is working and then tweak accordingly.
 * Instead of hardcoding a list of currently known behaviors and testing
 * for quirksmode, we do a runtime check out of the flow. The performance
 * impact should be very small.
 * @private
 */
goog.ui.Textarea.prototype.discoverTextareaCharacteristics_ = function() {
  'use strict';
  if (!this.hasDiscoveredTextareaCharacteristics_) {
    var textarea =
        /** @type {!HTMLElement} */ (this.getElement().cloneNode(false));
    // We need to overwrite/write box model specific styles that might
    // affect height.
    goog.style.setStyle(textarea, {
      'position': 'absolute',
      'height': 'auto',
      'top': '-9999px',
      'margin': '0',
      'padding': '1px',
      'border': '1px solid #000',
      'overflow': 'hidden'
    });
    goog.dom.appendChild(this.getDomHelper().getDocument().body, textarea);
    var initialScrollHeight = textarea.scrollHeight;

    textarea.style.padding = '10px';
    var paddingScrollHeight = textarea.scrollHeight;
    this.scrollHeightIncludesPadding_ =
        paddingScrollHeight > initialScrollHeight;

    initialScrollHeight = paddingScrollHeight;
    textarea.style.borderWidth = '10px';
    var borderScrollHeight = textarea.scrollHeight;
    this.scrollHeightIncludesBorder_ = borderScrollHeight > initialScrollHeight;

    // Tests if border-box sizing is working or not.
    textarea.style.height = '100px';
    var offsetHeightAtHeight100 = textarea.offsetHeight;
    if (offsetHeightAtHeight100 != 100) {
      this.needsPaddingBorderFix_ = true;
    }

    goog.dom.removeNode(textarea);
    this.hasDiscoveredTextareaCharacteristics_ = true;
  }
};


/**
 * The CSS class name to add to the input when the user has not entered a
 * value.
 */
goog.ui.Textarea.TEXTAREA_PLACEHOLDER_CLASS =
    goog.getCssName('textarea-placeholder-input');


/**
 * Called when the element goes out of focus.
 * @param {goog.events.Event=} opt_e The browser event.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.blur_ = function(opt_e) {
  'use strict';
  if (!this.supportsNativePlaceholder_()) {
    this.hasFocusForPlaceholder_ = false;
    if (this.getElement().value == '') {
      // Only transition to the default text if we have
      // no user input.
      this.hasUserInput_ = false;
      this.restorePlaceholder_();
    }
  }
};


/**
 * Resizes the textarea to grow/shrink to match its contents.
 * @param {goog.events.Event=} opt_e The browser event.
 * @private
 * @suppress {strictMissingProperties} Part of the go/strict_warnings_migration
 */
goog.ui.Textarea.prototype.grow_ = function(opt_e) {
  'use strict';
  if (this.isResizing_) {
    return;
  }
  var textarea = /** @type {!HTMLElement} */ (this.getElement());
  // If the element is getting focus and we don't support placeholders
  // natively, then remove the placeholder class.
  if (!this.supportsNativePlaceholder_() && opt_e &&
      opt_e.type == goog.events.EventType.FOCUS) {
    // We must have a textarea element, since we're growing it.
    // Remove the placeholder CSS + set the value to empty if we're currently
    // showing the placeholderText_ value if this is the first time we're
    // getting focus.
    if (textarea.value == this.placeholderText_ && this.placeholderText_ &&
        !this.hasFocusForPlaceholder_) {
      goog.dom.classlist.remove(
          textarea, goog.ui.Textarea.TEXTAREA_PLACEHOLDER_CLASS);
      textarea.value = '';
    }
    this.hasFocusForPlaceholder_ = true;
    this.hasUserInput_ = (textarea.value != '');
  }
  var shouldCallShrink = false;
  this.isResizing_ = true;
  var oldHeight = this.height_;
  if (textarea.scrollHeight) {
    var setMinHeight = false;
    var setMaxHeight = false;
    var newHeight = this.getHeight_();
    var currentHeight = textarea.offsetHeight;
    var minHeight = this.getMinHeight_();
    var maxHeight = this.getMaxHeight_();
    if (minHeight && newHeight < minHeight) {
      this.setHeight_(minHeight);
      setMinHeight = true;
    } else if (maxHeight && newHeight > maxHeight) {
      this.setHeight_(maxHeight);
      // If the content is greater than the height, we'll want the vertical
      // scrollbar back.
      textarea.style.overflowY = '';
      setMaxHeight = true;
    } else if (currentHeight != newHeight) {
      this.setHeight_(newHeight);
      // Makes sure that height_ is at least set.
    } else if (!this.height_) {
      this.height_ = newHeight;
    }
    if (!setMinHeight && !setMaxHeight &&
        goog.ui.Textarea.NEEDS_HELP_SHRINKING_) {
      shouldCallShrink = true;
    }
  } else {
    this.setHeightToEstimate_();
  }
  this.isResizing_ = false;

  if (shouldCallShrink) {
    this.shrink_();
  }
  if (oldHeight != this.height_) {
    this.dispatchEvent(goog.ui.Textarea.EventType.RESIZE);
  }
};


/**
 * Resizes the textarea to shrink to fit its contents. The way this works is
 * by increasing the padding of the textarea by 1px (it's important here that
 * we're in box-sizing: border-box mode). If the size of the textarea grows,
 * then the box is filled up to the padding box with text.
 * If it doesn't change, then we can shrink.
 * @private
 */
goog.ui.Textarea.prototype.shrink_ = function() {
  'use strict';
  var textarea = this.getElement();
  if (!this.isResizing_) {
    this.isResizing_ = true;
    var scrollHeight = textarea.scrollHeight;
    if (!scrollHeight) {
      this.setHeightToEstimate_();
    } else {
      var currentHeight = this.getHeight_();
      var minHeight = this.getMinHeight_();
      if (!(minHeight && currentHeight <= minHeight)) {
        // Nudge the padding by 1px.
        var paddingBox = this.paddingBox_;
        textarea.style.paddingTop = paddingBox.top + 1 + 'px';
        var heightAfterNudge = this.getHeight_();
        // If the one px of padding had no effect, then we can shrink.
        if (heightAfterNudge == currentHeight) {
          textarea.style.paddingTop = paddingBox.top + scrollHeight + 'px';
          textarea.scrollTop = 0;
          var shrinkToHeight = this.getHeight_() - scrollHeight;
          if (shrinkToHeight >= minHeight) {
            this.setHeight_(shrinkToHeight);
          } else {
            this.setHeight_(minHeight);
          }
        }
        textarea.style.paddingTop = paddingBox.top + 'px';
      }
    }
    this.isResizing_ = false;
  }
};


/**
 * We use this listener to check if the textarea has been natively resized
 * and if so we reset minHeight so that we don't ever shrink smaller than
 * the user's manually set height. Note that we cannot check size on mousedown
 * and then just compare here because we cannot capture mousedown on
 * the textarea resizer, while mouseup fires reliably.
 * @param {goog.events.BrowserEvent} e The mousedown event.
 * @private
 */
goog.ui.Textarea.prototype.mouseUpListener_ = function(e) {
  'use strict';
  var textarea = /** @type {!HTMLElement} */ (this.getElement());
  var height = textarea.offsetHeight;

  // This solves for when the MSIE DropShadow filter is enabled,
  // as it affects the offsetHeight value, even with MsBoxSizing:border-box.
  if (textarea['filters'] && textarea['filters'].length) {
    var dropShadow =
        textarea['filters']['item']('DXImageTransform.Microsoft.DropShadow');
    if (dropShadow) {
      height -= dropShadow['offX'];
    }
  }

  if (height != this.height_) {
    this.minHeight_ = height;
    this.height_ = height;
  }
};