diff --git a/extensions/amp-form/0.1/amp-form-textarea.js b/extensions/amp-form/0.1/amp-form-textarea.js new file mode 100644 index 000000000000..ea8b4eed82ca --- /dev/null +++ b/extensions/amp-form/0.1/amp-form-textarea.js @@ -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} + * @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} 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} + */ +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); +} diff --git a/extensions/amp-form/0.1/amp-form.css b/extensions/amp-form/0.1/amp-form.css index 1c669c3ef8f4..50f7eaebf74e 100644 --- a/extensions/amp-form/0.1/amp-form.css +++ b/extensions/amp-form/0.1/amp-form.css @@ -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%); diff --git a/extensions/amp-form/0.1/amp-form.js b/extensions/amp-form/0.1/amp-form.js index 044b28a15576..1a14336587ae 100644 --- a/extensions/amp-form/0.1/amp-form.js +++ b/extensions/amp-form/0.1/amp-form.js @@ -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, @@ -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); }); } diff --git a/extensions/amp-form/0.1/test/test-amp-form-textarea.js b/extensions/amp-form/0.1/test/test-amp-form-textarea.js new file mode 100644 index 000000000000..306cd90c1444 --- /dev/null +++ b/extensions/amp-form/0.1/test/test-amp-form-textarea.js @@ -0,0 +1,142 @@ +/** + * 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 {CSS} from '../../../../build/amp-form-0.1.css'; +import {Services} from '../../../../src/services'; +import { + getHasOverflow, + maybeResizeTextarea, +} from '../amp-form-textarea'; +import {installStylesForDoc} from '../../../../src/style-installer'; + +describes.realWin('amp-form textarea[autoexpand]', { + amp: { + ampdoc: 'single', + }, +}, env => { + let doc; + let sandbox; + beforeEach(() => { + doc = env.ampdoc.getRootNode(); + installStylesForDoc(env.ampdoc, CSS, () => {}, false, 'amp-form'); + sandbox = env.sandbox; + }); + + describe('AmpFormTextarea', () => { + it('should remove autoexpand on elements with initial overflow', () => { + + }); + }); + + describe('getHasOverflow', () => { + it('should detect if an element has overflow', () => { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autoexpand', ''); + textarea.setAttribute('rows', '1'); + textarea.setAttribute('cols', '80'); + textarea.innerHTML = 'big text'.repeat(30); + doc.body.appendChild(textarea); + + return expect(getHasOverflow(textarea)).to.eventually.be.true; + }); + + it('should detect if an element does not have overflow', () => { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autoexpand', ''); + textarea.setAttribute('rows', '1'); + textarea.setAttribute('cols', '80'); + textarea.innerHTML = 'small text'; + doc.body.appendChild(textarea); + + return expect(getHasOverflow(textarea)).to.eventually.be.false; + }); + }); + + describe('maybeResizeTextarea', () => { + it('should not resize an element that has not expanded', () => { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autoexpand', ''); + textarea.setAttribute('rows', '4'); + textarea.setAttribute('cols', '80'); + textarea.innerHTML = 'small text'; + doc.body.appendChild(textarea); + + const fakeResources = { + measureMutateElement(unusedElement, measurer, mutator) { + measurer(); + return mutator() || Promise.resolve(); + }, + }; + sandbox.stub(Services, 'resourcesForDoc').returns(fakeResources); + + const initialHeight = textarea.clientHeight; + return maybeResizeTextarea(textarea).then(() => { + expect(textarea.clientHeight).to.equal(initialHeight); + }); + }); + + it('should expand an element that exceeds its boundary', () => { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autoexpand', ''); + textarea.setAttribute('rows', '4'); + textarea.setAttribute('cols', '80'); + textarea.innerHTML = 'big text'.repeat(100); + doc.body.appendChild(textarea); + + const fakeResources = { + measureMutateElement(unusedElement, measurer, mutator) { + measurer(); + return mutator() || Promise.resolve(); + }, + }; + sandbox.stub(Services, 'resourcesForDoc').returns(fakeResources); + + const initialHeight = textarea.clientHeight; + return maybeResizeTextarea(textarea).then(() => { + expect(textarea.clientHeight).to.be.greaterThan(initialHeight); + }); + }); + + it('should shrink an element that expands and then reduces', () => { + const textarea = doc.createElement('textarea'); + textarea.setAttribute('autoexpand', ''); + textarea.setAttribute('rows', '4'); + textarea.setAttribute('cols', '80'); + textarea.innerHTML = 'big text'.repeat(100); + doc.body.appendChild(textarea); + + const fakeResources = { + measureMutateElement(unusedElement, measurer, mutator) { + measurer(); + return mutator() || Promise.resolve(); + }, + }; + sandbox.stub(Services, 'resourcesForDoc').returns(fakeResources); + + const initialHeight = textarea.clientHeight; + let increasedHeight; + return maybeResizeTextarea(textarea).then(() => { + increasedHeight = textarea.clientHeight; + expect(increasedHeight).to.be.greaterThan(initialHeight); + }).then(() => { + textarea.innerHTML = 'small text'; + return maybeResizeTextarea(textarea); + }).then(() => { + expect(textarea.clientHeight).to.be.lessThan(increasedHeight); + }); + }); + }); +}); diff --git a/extensions/amp-form/amp-form.md b/extensions/amp-form/amp-form.md index 38bbf29235f2..2c39dbe126bc 100644 --- a/extensions/amp-form/amp-form.md +++ b/extensions/amp-form/amp-form.md @@ -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 ` +``` + ## Styling ### Classes and CSS hooks diff --git a/src/purifier.js b/src/purifier.js index af3858155903..8927ceffda43 100644 --- a/src/purifier.js +++ b/src/purifier.js @@ -168,6 +168,9 @@ export const WHITELISTED_ATTRS_BY_TAGS = { 'template': [ 'type', ], + 'textarea': [ + 'autoexpand', + ], }; /** diff --git a/test/manual/amp-form-textarea-added.amp.html b/test/manual/amp-form-textarea-added.amp.html new file mode 100644 index 000000000000..d0ea59df246d --- /dev/null +++ b/test/manual/amp-form-textarea-added.amp.html @@ -0,0 +1,29 @@ + + + + + Forms Examples in AMP + + + + + + + + + +

Test lazy-adding of autoexpand

+ +
+ + +
+ +
+
+ + diff --git a/test/manual/amp-form-textarea.amp.html b/test/manual/amp-form-textarea.amp.html new file mode 100644 index 000000000000..e008be1f37d0 --- /dev/null +++ b/test/manual/amp-form-textarea.amp.html @@ -0,0 +1,106 @@ + + + + + Forms Examples in AMP + + + + + + + + + +

autoexpand text area

+ +

Copy paste text

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+ +
+ +

autoexpand

+

textarea element with autoexpand attribute which enables autoexpand and autoshink functionality

+ +

regular autoexpand

+ + +

box-sizing: content-box

+ + +

box-sizing: border-box

+ + +

wide to test window resize

+ + +

max-height: 100px

+ + +

min-height: 100px

+ + +

height: 100px

+ + +

lots of existing text

+ + +

wide, lots of existing text

+ + +

inside scrolling containers

+
+
+ +
+
+ +
+
+ +

amp-bind

+ + + + +

wow text below for spacing

+
+ + + + diff --git a/validator/testdata/feature_tests/forms.html b/validator/testdata/feature_tests/forms.html index b63644a5271e..c38ae00e52a5 100644 --- a/validator/testdata/feature_tests/forms.html +++ b/validator/testdata/feature_tests/forms.html @@ -202,5 +202,13 @@
+ +
+ +
+ +
+ +
diff --git a/validator/testdata/feature_tests/forms.out b/validator/testdata/feature_tests/forms.out index b7546a7d3810..6a3ecbb292e7 100644 --- a/validator/testdata/feature_tests/forms.out +++ b/validator/testdata/feature_tests/forms.out @@ -235,5 +235,13 @@ feature_tests/forms.html:193:6 The tag 'template' may not appear as a descendant >> ^~~~~~~~~ feature_tests/forms.html:203:4 The tag 'input [mask] (custom mask)' requires including the 'amp-inputmask' extension JavaScript. (see https://www.ampproject.org/docs/reference/components/amp-inputmask) [MANDATORY_AMP_TAG_MISSING_OR_INCORRECT] | +| +|
+| +|
+| +|
+| +|
| | \ No newline at end of file diff --git a/validator/validator-main.protoascii b/validator/validator-main.protoascii index 90c406d7058b..e66967effca7 100644 --- a/validator/validator-main.protoascii +++ b/validator/validator-main.protoascii @@ -4642,6 +4642,10 @@ tags: { html_format: ACTIONS tag_name: "TEXTAREA" attrs: { name: "autocomplete" } + attrs: { + name: "autoexpand" + requires_extension: "amp-form" + } attrs: { name: "autofocus" } attrs: { name: "cols" } attrs: { name: "disabled" }