Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Implement autoexpand for amp-form textarea element #20772

Merged
merged 15 commits into from
Feb 14, 2019
Merged
Show file tree
Hide file tree
Changes from 12 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
282 changes: 282 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,282 @@
/**
* 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} 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);
cvializ marked this conversation as resolved.
Show resolved Hide resolved
this.unlisteners_.push(this.viewport_.onResize(throttledResize));
}

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

/**
* 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.scrollHeight),
listenOncePromise(element, 'mouseup'),
]).then(results => {
const heightMouseDown = results[0];
let heightMouseUp = 0;

return resources.measureMutateElement(element, () => {
heightMouseUp = element.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}
*/
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.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);
}
}, () => {
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.scrollHeight < maxHeight);
}, () => {
// Prevent a jump from the textarea element scrolling
if (shouldKeepTop) {
textarea.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.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:not(.i-amphtml-textarea-max) {
overflow: hidden;
}

.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
7 changes: 7 additions & 0 deletions extensions/amp-form/amp-form.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,13 @@ The `amp-form` extension provides [classes](#classes-and-css-hooks) to polyfill

Regular expression matching is a common validation feature supported natively on most input elements, except for `<textarea>`. We polyfill this functionality and support the `pattern` attribute on `<textarea>` elements.

AMP Form provides an `autoexpand` attribute to `<textarea>` elements. This allows the textarea
to expand and shrink to accomodate the user's rows of input, up to the field's maximum size. If the user manually resizes the field, the autoexpand behavior will be removed.

```html
<textarea autoexpand></textarea>
```

## Styling

### Classes and CSS hooks
Expand Down
3 changes: 3 additions & 0 deletions src/purifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export const WHITELISTED_ATTRS_BY_TAGS = {
'template': [
'type',
],
'textarea': [
'autoexpand',
],
};

/**
Expand Down
29 changes: 29 additions & 0 deletions test/manual/amp-form-textarea-added.amp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<title>Forms Examples in AMP</title>
<link rel="canonical" href="amps.html" >
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<script async custom-element="amp-form" src="https://cdn.ampproject.org/v0/amp-form-0.1.js"></script>
<script async custom-template="amp-mustache" src="https://cdn.ampproject.org/v0/amp-mustache-0.2.js"></script>
<style amp-custom>
</style>
</head>
<body>
<h1>Test lazy-adding of autoexpand</h1>

<form method="post" action-xhr="/form/echo-json/post" target="_blank">
<label>Username<input name="username"></label>
<input type="submit">
<div submit-success>
<template type="amp-mustache">
<p>This should work</p>
<textarea autoexpand></textarea>
</template>
</div>
</form>
</body>
</html>
Loading