-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from 10 commits
b84980c
723b247
819ecf7
0022205
d210f70
b952cd5
1e32a5a
eed42bb
1fa46a9
4250030
b31db82
ca3c272
a53a758
fa2887d
4a58a7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 => { | ||
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 => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we aren't handling it on an |
||
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); | ||
} |
There was a problem hiding this comment.
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:
textarea[autoexpand]
element in the document (and re-query on DOM_UPDATE).input
event listener on the root element instead of each form (similar to action-impl.js).