Skip to content

Commit

Permalink
✨Implement autoexpand for amp-form textarea element (#20772)
Browse files Browse the repository at this point in the history
* Add expanding textarea feature to amp-form

* Add autoshrink behavior in addition to autoexpand

* reset

* reset2 it works

* Does not stay at top

* Prevent scrollbar from flashing before max-height

* default autoshrink. Prevent shrinking if the textarea hasn't yet expanded

* Use class style

* Add manual test

* Cleanup

* Review feedback. Remove autoshrink attr. Lazy-load feature.

* Encapsulate all textarea logic, including installation.

* Mark expensive property accesses as OK

* Warn and forbid textarea with initial overflow

* Add tests for confidence. Fix presubmit errors.
  • Loading branch information
cvializ authored Feb 14, 2019
1 parent 4679a4c commit 806db59
Show file tree
Hide file tree
Showing 11 changed files with 635 additions and 3 deletions.
311 changes: 311 additions & 0 deletions extensions/amp-form/0.1/amp-form-textarea.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
/**
* Copyright 2019 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {AmpEvents} from '../../../src/amp-events';
import {Services} from '../../../src/services';
import {computedStyle, px, setStyle} from '../../../src/style';
import {dev, devAssert, user} from '../../../src/log';
import {iterateCursor, removeElement} from '../../../src/dom';
import {listen, listenOncePromise} from '../../../src/event-helper';
import {throttle} from '../../../src/utils/rate-limit';

const AMP_FORM_TEXTAREA_EXPAND_ATTR = 'autoexpand';

const MIN_EVENT_INTERVAL_MS = 100;

const AMP_FORM_TEXTAREA_CLONE_CSS = 'i-amphtml-textarea-clone';

const AMP_FORM_TEXTAREA_MAX_CSS = 'i-amphtml-textarea-max';

const AMP_FORM_TEXTAREA_HAS_EXPANDED_DATA = 'iAmphtmlHasExpanded';

/**
* Install expandable textarea behavior for the given form.
*
* This class should be able to be removed when browsers implement
* `height: max-content` for the textarea element.
* https://github.com/w3c/csswg-drafts/issues/2141
*/
export class AmpFormTextarea {
/**
* Install, monitor and cleanup the document as `textarea[autoexpand]`
* elements are added and removed.
* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
*/
static install(ampdoc) {
const root = ampdoc.getRootNode();

let ampFormTextarea = null;
const maybeInstall = () => {
const autoexpandTextarea = root.querySelector('textarea[autoexpand]');
if (autoexpandTextarea && !ampFormTextarea) {
ampFormTextarea = new AmpFormTextarea(ampdoc);
return;
}

if (!autoexpandTextarea && ampFormTextarea) {
ampFormTextarea.dispose();
ampFormTextarea = null;
return;
}
};

listen(root, AmpEvents.DOM_UPDATE, maybeInstall);
maybeInstall();
}

/**
* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
*/
constructor(ampdoc) {
const root = ampdoc.getRootNode();

/** @private @const */
this.doc_ = (root.ownerDocument || root);

/** @private @const */
this.win_ = devAssert(this.doc_.defaultView);

/** @private @const */
this.viewport_ = Services.viewportForDoc(ampdoc);

/** @private */
this.unlisteners_ = [];

this.unlisteners_.push(listen(root, 'input', e => {
const element = dev().assertElement(e.target);
if (element.tagName != 'TEXTAREA' ||
!element.hasAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR)) {
return;
}

maybeResizeTextarea(element);
}));

this.unlisteners_.push(listen(root, 'mousedown', e => {
if (e.which != 1) {
return;
}

const element = dev().assertElement(e.target);
if (element.tagName != 'TEXTAREA' ||
!element.hasAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR)) {
return;
}

handleTextareaDrag(element);
}));

let cachedTextareaElements = root.querySelectorAll('textarea');
this.unlisteners_.push(listen(root, AmpEvents.DOM_UPDATE, () => {
cachedTextareaElements = root.querySelectorAll('textarea');
}));
const throttledResize = throttle(this.win_, e => {
if (e.relayoutAll) {
resizeTextareaElements(cachedTextareaElements);
}
}, MIN_EVENT_INTERVAL_MS);
this.unlisteners_.push(this.viewport_.onResize(throttledResize));

// For now, warn if textareas with initial overflow are present, and
// prevent them from becoming autoexpand textareas.
iterateCursor(cachedTextareaElements, element => {
getHasOverflow(element).then(hasOverflow => {
if (hasOverflow) {
user().warn('AMP-FORM',
'"textarea[autoexpand]" with initially scrolling content ' +
'will not autoexpand.\n' +
'See https://github.com/ampproject/amphtml/issues/20839');
element.removeAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR);
}
});
});
}

/**
* Cleanup any consumed resources
*/
dispose() {
this.unlisteners_.forEach(unlistener => unlistener());
}
}

/**
* Measure if any overflow is present on the element.
* @param {!Element} element
* @return {!Promise<boolean>}
* @visibleForTesting
*/
export function getHasOverflow(element) {
const resources = Services.resourcesForDoc(element);
return resources.measureElement(() => {
return element./*OK*/scrollHeight > element./*OK*/clientHeight;
});
}

/**
* Attempt to resize all textarea elements
* @param {!IArrayLike<!Element>} elements
*/
function resizeTextareaElements(elements) {
iterateCursor(elements, element => {
if (element.tagName != 'TEXTAREA' ||
!element.hasAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR)) {
return;
}

maybeResizeTextarea(element);
});
}

/**
* This makes no assumptions about the location of the resize handle, and it
* assumes that if the user drags the mouse at any position and the height of
* the textarea changes, then the user intentionally resized the textarea.
* @param {!Element} element
*/
function handleTextareaDrag(element) {
const resources = Services.resourcesForDoc(element);

Promise.all([
resources.measureElement(() => element./*OK*/scrollHeight),
listenOncePromise(element, 'mouseup'),
]).then(results => {
const heightMouseDown = results[0];
let heightMouseUp = 0;

return resources.measureMutateElement(element, () => {
heightMouseUp = element./*OK*/scrollHeight;
}, () => {
maybeRemoveResizeBehavior(element, heightMouseDown, heightMouseUp);
});
});
}

/**
* Remove the resize behavior if a user drags the resize handle and changes
* the height of the textarea.
* @param {!Element} element
* @param {number} startHeight
* @param {number} endHeight
*/
function maybeRemoveResizeBehavior(element, startHeight, endHeight) {
if (startHeight != endHeight) {
element.removeAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR);
}
}

/**
* Resize the textarea to fit its current text, by expanding or shrinking if
* needed.
* @param {!Element} element
* @return {!Promise}
* @visibleForTesting
*/
export function maybeResizeTextarea(element) {
const resources = Services.resourcesForDoc(element);
const win = devAssert(element.ownerDocument.defaultView);

let offset = 0;
let scrollHeight = 0;
let maxHeight = 0;

// The minScrollHeight is the minimimum height required to contain the
// text content without showing a scrollbar.
// This is different than scrollHeight, which is the larger of: 1. the
// element's content, or 2. the element itself.
const minScrollHeightPromise = getShrinkHeight(element);

return resources.measureMutateElement(element, () => {
const computed = computedStyle(win, element);
scrollHeight = element./*OK*/scrollHeight;

const maybeMaxHeight =
parseInt(computed.getPropertyValue('max-height'), 10);
maxHeight = isNaN(maybeMaxHeight) ? Infinity : maybeMaxHeight;

if (computed.getPropertyValue('box-sizing') == 'content-box') {
offset =
-parseInt(computed.getPropertyValue('padding-top'), 10) +
-parseInt(computed.getPropertyValue('padding-bottom'), 10);
} else {
offset =
parseInt(computed.getPropertyValue('border-top-width'), 10) +
parseInt(computed.getPropertyValue('border-bottom-width'), 10);
}
}, () => {
return minScrollHeightPromise.then(minScrollHeight => {
const height = minScrollHeight + offset;
// Prevent the scrollbar from appearing
// unless the text is beyond the max-height
element.classList.toggle(AMP_FORM_TEXTAREA_MAX_CSS, height > maxHeight);

// Prevent the textarea from shrinking if it has not yet expanded.
const hasExpanded =
AMP_FORM_TEXTAREA_HAS_EXPANDED_DATA in element.dataset;
const shouldResize = (hasExpanded || scrollHeight <= minScrollHeight);

if (shouldResize) {
element.dataset[AMP_FORM_TEXTAREA_HAS_EXPANDED_DATA] = '';
// Set the textarea height to the height of the text
setStyle(element, 'height', px(minScrollHeight + offset));
}
});
});
}

/**
* If shrink behavior is enabled, get the amount to shrink or expand. This
* uses a more expensive method to calculate the new height creating a temporary
* clone of the node and setting its height to 0 to get the minimum scrollHeight
* of the element's contents.
* @param {!Element} textarea
* @return {!Promise<number>}
*/
function getShrinkHeight(textarea) {
const doc = devAssert(textarea.ownerDocument);
const win = devAssert(doc.defaultView);
const body = devAssert(doc.body);
const resources = Services.resourcesForDoc(textarea);

const clone = textarea.cloneNode(/*deep*/ false);
clone.classList.add(AMP_FORM_TEXTAREA_CLONE_CSS);

let height = 0;
let shouldKeepTop = false;

return resources.measureMutateElement(body, () => {
const computed = computedStyle(win, textarea);
const maxHeight = parseInt(computed.getPropertyValue('max-height'), 10); // TODO(cvializ): what if it's a percent?

// maxHeight is NaN if the max-height property is 'none'.
shouldKeepTop =
(isNaN(maxHeight) || textarea./*OK*/scrollHeight < maxHeight);
}, () => {
// Prevent a jump from the textarea element scrolling
if (shouldKeepTop) {
textarea./*OK*/scrollTop = 0;
}
// Append the clone to the DOM so its scrollHeight can be read
doc.body.appendChild(clone);
}).then(() => {
return resources.measureMutateElement(body, () => {
height = clone./*OK*/scrollHeight;
}, () => {
removeElement(clone);
});
}).then(() => height);
}
11 changes: 11 additions & 0 deletions extensions/amp-form/0.1/amp-form.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ form.amp-form-submit-error [submit-error] {
display: block;
}

textarea[autoexpand]:not(.i-amphtml-textarea-max) {
overflow: hidden!important;
}

.i-amphtml-textarea-clone {
visibility: hidden;
position: absolute;
top: -9999px;
left: -9999px;
height: 0!important;
}

.i-amphtml-validation-bubble {
transform: translate(-50%, -100%);
Expand Down
9 changes: 6 additions & 3 deletions extensions/amp-form/0.1/amp-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import {ActionTrust} from '../../../src/action-constants';
import {AmpEvents} from '../../../src/amp-events';
import {AmpFormTextarea} from './amp-form-textarea';
import {
AsyncInputAttributes,
AsyncInputClasses,
Expand Down Expand Up @@ -1297,9 +1298,11 @@ export class AmpFormService {
*/
installHandlers_(ampdoc) {
return ampdoc.whenReady().then(() => {
this.installSubmissionHandlers_(
ampdoc.getRootNode().querySelectorAll('form'));
this.installGlobalEventListener_(ampdoc.getRootNode());
const root = ampdoc.getRootNode();

this.installSubmissionHandlers_(root.querySelectorAll('form'));
AmpFormTextarea.install(ampdoc);
this.installGlobalEventListener_(root);
});
}

Expand Down
Loading

0 comments on commit 806db59

Please sign in to comment.