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

✨[amp-form] allow form attributes for form elements outside of amp-form #33095

Merged
merged 24 commits into from
May 19, 2021
Merged
Show file tree
Hide file tree
Changes from 9 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
221 changes: 161 additions & 60 deletions extensions/amp-form/0.1/amp-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ import {
childElementByAttr,
createElementWithAttributes,
iterateCursor,
matches,
removeElement,
tryFocus,
} from '../../../src/dom';
import {createCustomEvent} from '../../../src/event-helper';
import {createCustomEvent, listen} from '../../../src/event-helper';
import {createFormDataWrapper} from '../../../src/form-data-wrapper';
import {deepMerge, dict} from '../../../src/utils/object';
import {deepMerge, dict, hasOwn} from '../../../src/utils/object';
import {dev, devAssert, user, userAssert} from '../../../src/log';
import {escapeCssSelectorIdent} from '../../../src/css';
import {
Expand All @@ -61,6 +62,7 @@ import {
} from '../../../src/form';
import {getFormValidator, isCheckValiditySupported} from './form-validators';
import {getMode} from '../../../src/mode';
import {getServiceForDocOrNull} from '../../../src/service';
import {installFormProxy} from './form-proxy';
import {installStylesForDoc} from '../../../src/style-installer';
import {isAmp4Email} from '../../../src/format';
Expand Down Expand Up @@ -142,6 +144,9 @@ export class AmpForm {
/** @const @private {!../../../src/service/ampdoc-impl.AmpDoc} */
this.ampdoc_ = Services.ampdoc(this.form_);

/** @const @private {?AmpFormService} */
this.ampFormService_ = getServiceForDocOrNull(this.ampdoc_, TAG);

/** @private {?Promise} */
this.dependenciesPromise_ = null;

Expand Down Expand Up @@ -214,7 +219,11 @@ export class AmpForm {
}

/** @const @private {!./form-dirtiness.FormDirtiness} */
this.dirtinessHandler_ = new FormDirtiness(this.form_, this.win_);
this.dirtinessHandler_ = new FormDirtiness(
this.form_,
this.win_,
this.ampFormService_
);

/** @const @private {!./form-validators.FormValidator} */
this.validator_ = getFormValidator(this.form_);
Expand Down Expand Up @@ -388,6 +397,9 @@ export class AmpForm {
* Returns a promise that will be resolved when all dependencies used inside
* the form tag are loaded and built (e.g. amp-selector) or 2 seconds timeout
* - whichever is first.
*
* NOTE: amp-form allows <input>'s that are not descendants of itself, but
* not <amp-selector>s
micajuine-ho marked this conversation as resolved.
Show resolved Hide resolved
* @return {!Promise}
* @private
*/
Expand All @@ -414,64 +426,70 @@ export class AmpForm {
tryFocus(autofocus);
}
});
if (this.ampFormService_) {
micajuine-ho marked this conversation as resolved.
Show resolved Hide resolved
this.ampFormService_.addFormEventListener(
this.form_,
'submit',
this.handleSubmitEvent_.bind(this),
true
);

this.form_.addEventListener(
'submit',
this.handleSubmitEvent_.bind(this),
true
);

this.form_.addEventListener(
'blur',
(e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onBlur(e);
},
true
);

this.form_.addEventListener(
AmpEvents.FORM_VALUE_CHANGE,
(e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onInput(e);
},
true
);

// Form verification is not supported when SSRing templates is enabled.
if (!this.ssrTemplateHelper_.isEnabled()) {
this.form_.addEventListener('change', (e) => {
this.verifier_.onCommit().then((updatedErrors) => {
const {updatedElements, errors} = updatedErrors;
updatedElements.forEach(checkUserValidityAfterInteraction_);
// Tell the validation to reveal any input.validationMessage added
// by the form verifier.
this.ampFormService_.addFormEventListener(
this.form_,
'blur',
(e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onBlur(e);
},
true
);

this.ampFormService_.addFormEventListener(
this.form_,
AmpEvents.FORM_VALUE_CHANGE,
(e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onInput(e);
},
true
);

// Only make the verify XHR if the user hasn't pressed submit.
if (this.state_ === FormState.VERIFYING) {
if (errors.length) {
this.setState_(FormState.VERIFY_ERROR);
this.renderTemplate_(dict({'verifyErrors': errors})).then(() => {
this.triggerAction_(
FormEvents.VERIFY_ERROR,
errors,
ActionTrust.DEFAULT // DEFAULT because async after gesture.
// Form verification is not supported when SSRing templates is enabled.
if (!this.ssrTemplateHelper_.isEnabled()) {
this.ampFormService_.addFormEventListener(this.form_, 'change', (e) => {
this.verifier_.onCommit().then((updatedErrors) => {
const {updatedElements, errors} = updatedErrors;
updatedElements.forEach(checkUserValidityAfterInteraction_);
// Tell the validation to reveal any input.validationMessage added
// by the form verifier.
this.validator_.onBlur(e);

// Only make the verify XHR if the user hasn't pressed submit.
if (this.state_ === FormState.VERIFYING) {
if (errors.length) {
this.setState_(FormState.VERIFY_ERROR);
this.renderTemplate_(dict({'verifyErrors': errors})).then(
() => {
this.triggerAction_(
FormEvents.VERIFY_ERROR,
errors,
ActionTrust.DEFAULT // DEFAULT because async after gesture.
);
}
);
});
} else {
this.setState_(FormState.INITIAL);
} else {
this.setState_(FormState.INITIAL);
}
}
}
});
});
}

this.ampFormService_.addFormEventListener(this.form_, 'change', (e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onInput(e);
});
}

this.form_.addEventListener('input', (e) => {
checkUserValidityAfterInteraction_(dev().assertElement(e.target));
this.validator_.onInput(e);
});
}

/** @private */
Expand Down Expand Up @@ -536,7 +554,8 @@ export class AmpForm {
this.form_.classList.remove('user-valid');
this.form_.classList.remove('user-invalid');

const validityElements = this.form_.querySelectorAll(
const validityElements = formElementsQuerySelectorAll(
this.form_,
'.user-valid, .user-invalid'
);
iterateCursor(validityElements, (element) => {
Expand Down Expand Up @@ -694,12 +713,19 @@ export class AmpForm {

/**
* Get form fields that require variable substitutions
* @return {!IArrayLike<!HTMLInputElement>}
* @return {!Array<!HTMLInputElement>}
* @private
*/
getVarSubsFields_() {
// Fields that support var substitutions.
return this.form_.querySelectorAll('[type="hidden"][data-amp-replace]');
const {elements} = this.form_;
return Array.from(elements).filter((ele) => {
return (
ele.tagName.toUpperCase() === 'INPUT' &&
ele.hasAttribute('data-amp-replace') &&
ele.type.toLocaleLowerCase() === 'hidden'
);
});
}

/**
Expand Down Expand Up @@ -937,7 +963,8 @@ export class AmpForm {
*/
doVerifyXhr_() {
const noVerifyFields = toArray(
this.form_.querySelectorAll(
formElementsQuerySelectorAll(
this.form_,
`[${escapeCssSelectorIdent(FORM_VERIFY_OPTOUT)}]`
)
);
Expand Down Expand Up @@ -1125,7 +1152,8 @@ export class AmpForm {
* @private
*/
assertNoSensitiveFields_() {
const fields = this.form_.querySelectorAll(
const fields = formElementsQuerySelectorAll(
this.form_,
'input[type=password],input[type=file]'
);
userAssert(
Expand Down Expand Up @@ -1442,13 +1470,34 @@ export class AmpForm {
}
}

/**
* Returns all element who's form attribute is the `form`
* that match the selectors.
* These elements must be contained by the form or <input>s
* that are outside the form.
* @param {!HTMLFormElement} form
* @param {string} query
* @return {!Array<HTMLElement>}
*/
export function formElementsQuerySelectorAll(form, query) {
return Array.from(form.elements).filter((element) => {
micajuine-ho marked this conversation as resolved.
Show resolved Hide resolved
return (
matches(element, query) &&
(form.contains(element) || element.tagName.toUpperCase() === 'INPUT')
micajuine-ho marked this conversation as resolved.
Show resolved Hide resolved
);
});
}

/**
* Checks user validity for all inputs, fieldsets and the form.
* @param {!HTMLFormElement} form
* @return {boolean} Whether the form is currently valid or not.
*/
function checkUserValidityOnSubmission(form) {
const elements = form.querySelectorAll('input,select,textarea,fieldset');
const elements = formElementsQuerySelectorAll(
form,
'input,select,textarea,fieldset'
);
iterateCursor(elements, (element) => checkUserValidity(element));
return checkUserValidity(form);
}
Expand Down Expand Up @@ -1489,7 +1538,8 @@ function updateInvalidTypesClasses(element) {
function removeValidityStateClasses(form) {
const dummyInput = document.createElement('input');
for (const validityState in dummyInput.validity) {
const elements = form.querySelectorAll(
const elements = formElementsQuerySelectorAll(
form,
`.${escapeCssSelectorIdent(validityState)}`
);
iterateCursor(elements, (element) => {
Expand Down Expand Up @@ -1572,6 +1622,7 @@ export function checkUserValidityAfterInteraction_(input) {

/**
* Bootstraps the amp-form elements
* @implements {../../src/service.Disposable}
*/
export class AmpFormService {
/**
Expand All @@ -1583,6 +1634,15 @@ export class AmpFormService {
this.installHandlers_(ampdoc)
);

/** @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc */
this.ampdoc_ = ampdoc;

/** @const @private {!Array<UnlistenDef>} */
this.unlisteners_ = [];

/** @const @private {!Object<string, WeakMap<HTMLFormElement, function(!Event)>>} */
this.eventHandlers_ = {};

// Dispatch a test-only event for integration tests.
if (getMode().test) {
this.whenInitialized_.then(() => {
Expand Down Expand Up @@ -1663,6 +1723,47 @@ export class AmpFormService {
});
}

/** @override */
dispose() {
while (this.unlisteners_.length > 0) {
const unlisten = this.unlisteners_.pop();
unlisten();
}
}

/**
* Adds handler for the form for a given type, when the
* rootNode gets the signal.
* @param {!HTMLFormElement} form
* @param {string} type
* @param {function(!Event)} handler
* @param {boolean=} opt_options
*/
addFormEventListener(form, type, handler, opt_options) {
if (!hasOwn(this.eventHandlers_, type)) {
this.eventHandlers_[type] = new WeakMap();
this.unlisteners_.push(
listen(
this.ampdoc_.getRootNode(),
type,
(e) => {
const {form} = e.target;

// Only call handler if the elemen has a registered form.
micajuine-ho marked this conversation as resolved.
Show resolved Hide resolved
if (this.eventHandlers_[type].has(form)) {
this.eventHandlers_[type].get(form)(e);
}
},
opt_options
)
);
}
this.eventHandlers_[type].set(form, handler);
this.unlisteners_.push(() => {
this.eventHandlers_[type].delete(form);
});
}

/**
* Listen for Ctrl/Cmd + Enter in textarea elements
* to trigger form submission when relevant.
Expand Down
Loading