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 10 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
239 changes: 239 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,239 @@
/**
* 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 {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 AMP_FORM_TEXTAREA_SHRINK_DISABLED_ATTR = 'autoshrink-disabled';

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';

class AmpFormWithTextarea {
/**
* @param {!Element} form
*/
constructor(form) {
/** @private */
this.win_ = devAssert(form.ownerDocument.defaultView);

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

this.unlisteners_.push(listen(form, 'input', e => {

Choose a reason for hiding this comment

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

I might've said something like this in chat, but maybe we can reduce the number of event listeners from O(# of forms) to O(1) by:

  1. Only installing a listener if there exists a textarea[autoexpand] element in the document (and re-query on DOM_UPDATE).
  2. Add input event listener on the root element instead of each form (similar to action-impl.js).

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

maybeResizeTextarea(element);
}));

this.unlisteners_.push(listen(form, '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;
}

maybeRemoveResizeBehavior(element);
}));

const throttledResize = throttle(this.win_, () => {
resizeFormTextareaElements(form);
}, MIN_EVENT_INTERVAL_MS);
cvializ marked this conversation as resolved.
Show resolved Hide resolved
this.unlisteners_.push(listen(this.win_, 'resize', throttledResize));
}

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

/**
* Install expandable textarea behavior for the given form.
*
* This method can be removed when browsers implement `height: max-content`
* for the textarea element.
* https://github.com/w3c/csswg-drafts/issues/2141
* @param {!Element} form
*/
export function installAmpFormTextarea(form) {
new AmpFormWithTextarea(form);
}

/**
* Attempt to resize all textarea elements within the given form.
* @param {!Element} form
*/
function resizeFormTextareaElements(form) {
iterateCursor(form.getElementsByTagName('textarea'), element => {

Choose a reason for hiding this comment

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

HTMLFormElement.elements to get non-descendant inputs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we aren't handling it on an O(#forms) basis, then I think I'll use rootNode.querySelectorAll to query for the elements

if (!element.hasAttribute(AMP_FORM_TEXTAREA_EXPAND_ATTR)) {
return;
}

maybeResizeTextarea(element);
});
}

/**
* Remove the resize behavior if a user drags the resize handle and changes
* the height of the textarea.
* 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
*/
export function maybeRemoveResizeBehavior(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;
}, () => {
if (heightMouseDown != heightMouseUp) {
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}
*/
export function maybeResizeTextarea(element) {
const resources = Services.resourcesForDoc(element);
const win = devAssert(element.ownerDocument.defaultView);

let offset = 0;
let scrollHeight = 0;
let minScrollHeightPromise = null;
let maxHeight = 0;

if (!element.hasAttribute(AMP_FORM_TEXTAREA_SHRINK_DISABLED_ATTR)) {
minScrollHeightPromise = getShrinkHeight(element);
}

return resources.measureMutateElement(element, () => {
const computed = computedStyle(win, element);
scrollHeight = element.scrollHeight;
if (minScrollHeightPromise == null) {
minScrollHeightPromise = Promise.resolve(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
3 changes: 3 additions & 0 deletions extensions/amp-form/0.1/amp-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
isCheckValiditySupported,
} from './form-validators';
import {getMode} from '../../../src/mode';
import {installAmpFormTextarea} from './amp-form-textarea';
import {installFormProxy} from './form-proxy';
import {installStylesForDoc} from '../../../src/style-installer';
import {
Expand Down Expand Up @@ -207,6 +208,8 @@ export class AmpForm {
this.form_, this.actionHandler_.bind(this), ActionTrust.HIGH);
this.installEventHandlers_();
this.installInputMasking_();
installAmpFormTextarea(this.form_);


/** @private {?Promise} */
this.xhrSubmitPromise_ = null;
Expand Down
13 changes: 13 additions & 0 deletions extensions/amp-form/amp-form.md
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,19 @@ 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>
```

AMP Form also provides an `autoshrink-disabled` attribute to use in conjunction with the `autoexpand` attribute on `<textarea>` elements. This prevents the textarea from reducing its size after the user deletes text from the element.

```html
<textarea autoexpand autoshrink-disabled></textarea>
```

## Styling

### Classes and CSS hooks
Expand Down
Loading