Source code

Revision control

Copy as Markdown

Other Tools

Test Info:

<!DOCTYPE html>
<meta charset="utf-8">
<title>HTMLSubmitButtonBehavior form submission behavior</title>
<link rel="author" href="mailto:ansollan@microsoft.com">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<style>
custom-submit {
display: inline-block;
width: 100px;
height: 30px;
}
:default {
outline: 3px solid blue;
}
</style>
<form>
<input name="field" value="value">
<custom-submit></custom-submit>
<non-form-submit></non-form-submit>
</form>
<div id="outside"></div>
<script>
class CustomSubmitButton extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.behavior_ = new HTMLSubmitButtonBehavior();
this.internals_ = this.attachInternals({ behaviors: [this.behavior_] });
}
get submitBehavior() { return this.behavior_; }
}
customElements.define('custom-submit', CustomSubmitButton);
class NonFormAssociatedSubmit extends HTMLElement {
constructor() {
super();
this.attachInternals({ behaviors: [new HTMLSubmitButtonBehavior()] });
}
}
customElements.define('non-form-submit', NonFormAssociatedSubmit);
const form = document.querySelector('form');
const outside = document.querySelector('#outside');
const customSubmit = form.querySelector('custom-submit');
const nonFormSubmit = form.querySelector('non-form-submit');
const defaultInput = form.querySelector('input');
function reset() {
form.replaceChildren();
form.removeAttribute('action');
form.removeAttribute('id');
form.removeAttribute('method');
form.removeAttribute('target');
form.onsubmit = null;
outside.replaceChildren();
customSubmit.removeAttribute('disabled');
customSubmit.removeAttribute('form');
customSubmit.removeAttribute('id');
defaultInput.value = 'value';
defaultInput.required = false;
customSubmit.submitBehavior.disabled = false;
customSubmit.submitBehavior.formAction = '';
customSubmit.submitBehavior.formEnctype = '';
customSubmit.submitBehavior.formMethod = '';
customSubmit.submitBehavior.formNoValidate = false;
customSubmit.submitBehavior.formTarget = '';
customSubmit.submitBehavior.name = '';
customSubmit.submitBehavior.value = '';
form.appendChild(nonFormSubmit);
form.appendChild(customSubmit);
form.prepend(defaultInput);
}
// Creates a native <button type="submit"> and adds it to the form.
function addNativeSubmitButton({prepend = false} = {}) {
const nativeButton = document.createElement('button');
nativeButton.type = 'submit';
if (prepend) {
form.prepend(nativeButton);
} else {
form.appendChild(nativeButton);
}
return nativeButton;
}
// Clicks an element and returns whether the form's submit event fired.
function clickSubmitsForm(element) {
let submitted = false;
form.onsubmit = (e) => {
e.preventDefault();
submitted = true;
};
element.click();
form.onsubmit = null;
return submitted;
}
// Clicks a submitter, intercepts the submit event, and resolves with the
// resulting FormData.
function submitAndCollectFormData(submitter) {
return new Promise(resolve => {
form.addEventListener(
'submit',
(e) => {
e.preventDefault();
resolve(new FormData(form));
},
{ once: true });
submitter.click();
});
}
// Clicks a submitter and navigates into a hidden iframe, then resolves with
// the iframe's contentWindow. Callers can inspect location, document, etc.
function submitIntoIframe(submitter) {
const iframe = document.createElement('iframe');
iframe.name = 'submit-target';
document.body.appendChild(iframe);
form.setAttribute('target', iframe.name);
return new Promise(resolve => {
iframe.addEventListener('load', () => {
if (iframe.contentWindow.location.href === 'about:blank') {
return;
}
resolve(iframe.contentWindow);
iframe.remove();
});
submitter.click();
});
}
test(() => {
assert_equals(typeof HTMLSubmitButtonBehavior, 'function',
'HTMLSubmitButtonBehavior should be a function');
const behavior = new HTMLSubmitButtonBehavior();
assert_true(behavior instanceof HTMLSubmitButtonBehavior,
'new HTMLSubmitButtonBehavior() should create an instance');
}, 'HTMLSubmitButtonBehavior is a global constructor');
test(() => {
const behavior = new HTMLSubmitButtonBehavior();
assert_true('disabled' in behavior, 'behavior should have disabled property');
assert_true('form' in behavior, 'behavior should have form property');
assert_true('formAction' in behavior, 'behavior should have formAction property');
assert_true('formMethod' in behavior, 'behavior should have formMethod property');
assert_true('formEnctype' in behavior, 'behavior should have formEnctype property');
assert_true('formNoValidate' in behavior, 'behavior should have formNoValidate property');
assert_true('formTarget' in behavior, 'behavior should have formTarget property');
assert_true('labels' in behavior, 'behavior should have labels property');
assert_true('name' in behavior, 'behavior should have name property');
assert_true('value' in behavior, 'behavior should have value property');
}, 'HTMLSubmitButtonBehavior has expected properties');
test(t => {
t.add_cleanup(reset);
outside.appendChild(customSubmit);
const behavior = customSubmit.submitBehavior;
assert_equals(behavior.disabled, false, 'disabled should default to false');
assert_equals(behavior.form, null, 'form should default to null when not inside a form');
assert_equals(behavior.formAction, '', 'formAction should default to empty string');
assert_equals(behavior.formMethod, '', 'formMethod should default to empty string');
assert_equals(behavior.formEnctype, '', 'formEnctype should default to empty string');
assert_equals(behavior.formNoValidate, false, 'formNoValidate should default to false');
assert_equals(behavior.formTarget, '', 'formTarget should default to empty string');
assert_equals(behavior.labels.length, 0, 'labels should default to empty NodeList');
assert_equals(behavior.name, '', 'name should default to empty string');
assert_equals(behavior.value, '', 'value should default to empty string');
}, 'HTMLSubmitButtonBehavior properties have correct default values');
test(() => {
const behavior = new HTMLSubmitButtonBehavior();
behavior.disabled = true;
assert_equals(behavior.disabled, true, 'disabled should be writable');
behavior.formAction = '/submit';
assert_equals(behavior.formAction, '/submit', 'formAction should be writable');
behavior.formMethod = 'post';
assert_equals(behavior.formMethod, 'post', 'formMethod should be writable');
behavior.formEnctype = 'multipart/form-data';
assert_equals(behavior.formEnctype, 'multipart/form-data', 'formEnctype should be writable');
behavior.formNoValidate = true;
assert_equals(behavior.formNoValidate, true, 'formNoValidate should be writable');
behavior.formTarget = '_blank';
assert_equals(behavior.formTarget, '_blank', 'formTarget should be writable');
behavior.name = 'submitBtn';
assert_equals(behavior.name, 'submitBtn', 'name should be writable');
behavior.value = 'Submit';
assert_equals(behavior.value, 'Submit', 'value should be writable');
}, 'HTMLSubmitButtonBehavior properties are writable');
test(() => {
const formDesc = Object.getOwnPropertyDescriptor(
HTMLSubmitButtonBehavior.prototype, 'form');
const labelsDesc = Object.getOwnPropertyDescriptor(
HTMLSubmitButtonBehavior.prototype, 'labels');
assert_true(formDesc !== undefined && 'get' in formDesc,
'form should be an accessor property');
assert_equals(formDesc.set, undefined, 'form should not have a setter');
assert_true(labelsDesc !== undefined && 'get' in labelsDesc,
'labels should be an accessor property');
assert_equals(labelsDesc.set, undefined, 'labels should not have a setter');
}, 'HTMLSubmitButtonBehavior.form and .labels are read-only');
test(t => {
t.add_cleanup(reset);
assert_equals(customSubmit.submitBehavior.form, form);
}, 'HTMLSubmitButtonBehavior.form returns the associated form');
test(t => {
t.add_cleanup(reset);
outside.appendChild(customSubmit);
customSubmit.removeAttribute('form');
assert_equals(customSubmit.submitBehavior.form, null);
}, 'HTMLSubmitButtonBehavior.form returns null when not in form');
test(t => {
t.add_cleanup(reset);
customSubmit.id = 'labeled-element';
const label = document.createElement('label');
label.htmlFor = 'labeled-element';
outside.appendChild(label);
outside.appendChild(customSubmit);
const labels = customSubmit.submitBehavior.labels;
assert_equals(labels.length, 1);
assert_equals(labels[0], label);
}, 'HTMLSubmitButtonBehavior.labels returns associated labels');
promise_test(async t => {
t.add_cleanup(reset);
const formData = await submitAndCollectFormData(customSubmit);
assert_equals(formData.get('field'), 'value',
'Form should be submitted with input values');
}, 'Click on custom element with HTMLSubmitButtonBehavior submits form');
promise_test(async t => {
t.add_cleanup(reset);
let submitted = false;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitted = true;
}, { once: true });
defaultInput.focus();
await test_driver.send_keys(defaultInput, '\uE007');
assert_true(submitted,
'Pressing Enter in a text field should trigger implicit submission via custom submit button');
}, 'Custom element with HTMLSubmitButtonBehavior participates in implicit submission');
promise_test(async t => {
t.add_cleanup(reset);
let clickFired = false;
customSubmit.addEventListener('click', () => {
clickFired = true;
}, { once: true });
form.onsubmit = (e) => {
e.preventDefault();
};
defaultInput.focus();
await test_driver.send_keys(defaultInput, '\uE007');
assert_true(clickFired,
'Implicit submission should dispatch a click event on the custom submit button');
}, 'Implicit submission dispatches click on custom element with HTMLSubmitButtonBehavior');
promise_test(async t => {
t.add_cleanup(reset);
let submitEventFired = false;
let submitEventTarget = null;
await new Promise(resolve => {
form.addEventListener('submit', ev => {
ev.preventDefault();
submitEventFired = true;
submitEventTarget = ev.target;
resolve();
}, { once: true });
customSubmit.click();
});
assert_true(submitEventFired, 'submit event should fire');
assert_equals(submitEventTarget, form, 'submit event target should be the form');
}, 'Submit event fires when clicking custom submit button');
test(t => {
t.add_cleanup(reset);
customSubmit.submitBehavior.disabled = true;
assert_false(clickSubmitsForm(customSubmit),
'Form should not be submitted when behavior is disabled');
}, 'Disabled HTMLSubmitButtonBehavior does not submit form');
test(t => {
t.add_cleanup(reset);
customSubmit.setAttribute('disabled', '');
assert_false(clickSubmitsForm(customSubmit),
'Form should not be submitted when element has disabled attribute');
}, 'Element disabled attribute prevents HTMLSubmitButtonBehavior submission');
test(t => {
t.add_cleanup(reset);
const fieldset = document.createElement('fieldset');
fieldset.disabled = true;
fieldset.appendChild(customSubmit);
form.appendChild(fieldset);
assert_false(clickSubmitsForm(customSubmit),
'Form should not be submitted when ancestor fieldset is disabled');
}, 'Ancestor fieldset disabled prevents HTMLSubmitButtonBehavior submission');
test(t => {
t.add_cleanup(reset);
const fieldset = document.createElement('fieldset');
fieldset.disabled = true;
const legend = document.createElement('legend');
legend.appendChild(customSubmit);
fieldset.appendChild(legend);
form.appendChild(fieldset);
let submitted = false;
form.onsubmit = (e) => { e.preventDefault(); submitted = true; };
customSubmit.click();
assert_true(submitted,
'Form should be submitted when element is in the first legend of a disabled fieldset');
}, 'First legend exception: custom submit in first legend of disabled fieldset can still submit');
promise_test(async t => {
t.add_cleanup(reset);
let submitCount = 0;
form.onsubmit = (e) => {
e.preventDefault();
submitCount++;
};
customSubmit.click();
assert_equals(submitCount, 1, 'Should submit when neither behavior nor element is disabled');
customSubmit.submitBehavior.disabled = true;
customSubmit.click();
assert_equals(submitCount, 1, 'Should not submit when behavior.disabled is true');
customSubmit.submitBehavior.disabled = false;
customSubmit.setAttribute('disabled', '');
customSubmit.click();
assert_equals(submitCount, 1, 'Should not submit when element has disabled attribute');
customSubmit.removeAttribute('disabled');
customSubmit.click();
assert_equals(submitCount, 2, 'Should submit after removing disabled attribute');
}, 'Submission blocked when behavior.disabled or element disabled attribute is set');
promise_test(async t => {
t.add_cleanup(reset);
form.setAttribute('action', '/should-not-be-used.html');
customSubmit.submitBehavior.formAction = '/common/blank.html';
const win = await submitIntoIframe(customSubmit);
assert_equals(win.location.pathname, '/common/blank.html',
'Form should be submitted to the behavior\'s formAction, not the form\'s action');
}, 'formAction on HTMLSubmitButtonBehavior overrides form action');
test(t => {
t.add_cleanup(reset);
defaultInput.value = '';
defaultInput.required = true;
customSubmit.submitBehavior.formNoValidate = true;
assert_true(clickSubmitsForm(customSubmit),
'Form should submit despite invalid field');
}, 'formNoValidate on HTMLSubmitButtonBehavior bypasses form validation');
promise_test(async t => {
t.add_cleanup(reset);
customSubmit.submitBehavior.formMethod = 'post';
form.setAttribute('action', '/common/blank.html');
const win = await submitIntoIframe(customSubmit);
assert_false(win.location.search.includes('field=value'),
'POST form method should not include form data in URL query string');
}, 'formMethod on HTMLSubmitButtonBehavior overrides form method');
promise_test(async t => {
t.add_cleanup(reset);
form.setAttribute('method', 'post');
customSubmit.submitBehavior.formEnctype = 'text/plain';
form.setAttribute('action',
'/html/semantics/forms/form-submission-0/form-echo.py');
const win = await submitIntoIframe(customSubmit);
// text/plain encoding ends each entry with CRLF (0d 0a), which is absent
// in the default application/x-www-form-urlencoded encoding.
const body = win.document.body.textContent;
assert_true(body.trim().endsWith('0d 0a'),
'text/plain enctype should produce CRLF-terminated body');
}, 'formEnctype on HTMLSubmitButtonBehavior overrides form enctype');
promise_test(async t => {
t.add_cleanup(reset);
customSubmit.submitBehavior.name = 'action';
customSubmit.submitBehavior.value = 'submit-clicked';
form.setAttribute('action', '/common/blank.html');
const win = await submitIntoIframe(customSubmit);
assert_true(win.location.search.includes('action=submit-clicked'),
'Button name and value should be submitted');
}, 'name and value on HTMLSubmitButtonBehavior are submitted with form');
test(t => {
t.add_cleanup(reset);
outside.appendChild(customSubmit);
assert_false(clickSubmitsForm(customSubmit),
'Form should not be submitted by element outside form');
}, 'Custom element with HTMLSubmitButtonBehavior outside form does not submit');
test(t => {
t.add_cleanup(reset);
assert_false(clickSubmitsForm(nonFormSubmit),
'Form should not be submitted by non-form-associated element');
}, 'Non-form-associated custom element with HTMLSubmitButtonBehavior does not submit');
promise_test(async t => {
t.add_cleanup(reset);
const secondSubmit = document.createElement('custom-submit');
form.appendChild(secondSubmit);
customSubmit.submitBehavior.name = 'btn';
customSubmit.submitBehavior.value = 'first';
secondSubmit.submitBehavior.name = 'btn';
secondSubmit.submitBehavior.value = 'second';
form.setAttribute('action', '/common/blank.html');
const win = await submitIntoIframe(customSubmit);
assert_true(win.location.search.includes('btn=first'),
'Clicking first custom submit should submit its name/value');
assert_false(win.location.search.includes('btn=second'),
'Only the clicked button\'s name/value should be submitted');
}, 'Only the clicked custom submit button\'s name/value is included in form data');
test(t => {
t.add_cleanup(reset);
let customClickFired = false;
customSubmit.addEventListener('click', () => {
customClickFired = true;
}, { once: true });
customSubmit.remove();
const submitted = clickSubmitsForm(customSubmit);
assert_true(customClickFired,
'Custom element click handler should fire when disconnected');
assert_false(submitted,
'Form should not be submitted by disconnected custom element');
}, 'Dispatching click on disconnected element fires event handlers but does not submit form');
promise_test(async t => {
t.add_cleanup(reset);
let submitted = false;
form.onsubmit = (e) => { e.preventDefault(); submitted = true; };
await test_driver.click(customSubmit);
assert_true(submitted,
'User click on custom submit button should submit form');
}, 'User-initiated click submits form via HTMLSubmitButtonBehavior');
test(t => {
t.add_cleanup(reset);
const nativeButton = addNativeSubmitButton({prepend: true});
assert_true(nativeButton.matches(':default'),
'Native button should match :default when it appears first');
}, 'Native button takes precedence over custom element when first in DOM');
test(t => {
t.add_cleanup(reset);
const nativeButton = addNativeSubmitButton();
assert_true(customSubmit.matches(':default'),
'Custom element should match :default when it appears first');
assert_false(nativeButton.matches(':default'),
'Native button should not match :default when custom element is first');
}, 'Custom element takes precedence over native button when first in DOM');
test(t => {
t.add_cleanup(reset);
assert_equals(getComputedStyle(customSubmit).outlineColor, 'rgb(0, 0, 255)',
'Custom element matching :default should have blue outline');
}, 'Styling via :default selector applies to custom submit button');
test(t => {
t.add_cleanup(reset);
assert_true(customSubmit.matches(':default'), 'Custom should initially match :default');
customSubmit.submitBehavior.disabled = true;
assert_true(customSubmit.matches(':default'),
'Disabled custom element should still match :default, like native disabled submit buttons');
}, 'Disabled custom element still matches :default');
test(t => {
t.add_cleanup(reset);
const secondSubmit = document.createElement('custom-submit');
form.appendChild(secondSubmit);
customSubmit.submitBehavior.disabled = true;
assert_true(customSubmit.matches(':default'),
'Disabled first custom submit should still match :default');
assert_false(secondSubmit.matches(':default'),
'Second custom submit should not match :default when first is still default');
}, 'Two custom submit buttons: disabled first is still :default');
test(t => {
t.add_cleanup(reset);
assert_false(nonFormSubmit.matches(':default'),
'Non-form-associated element should not match :default');
}, 'Non-form-associated custom element with HTMLSubmitButtonBehavior does not match :default');
promise_test(async t => {
t.add_cleanup(reset);
form.setAttribute('id', 'test-form');
customSubmit.setAttribute('form', 'test-form');
outside.appendChild(customSubmit);
// The behavior's read-only `form` property reflects the same
// association exposed by ElementInternals.form.
assert_equals(customSubmit.submitBehavior.form, form,
'behavior.form should reflect the form attribute association');
assert_equals(customSubmit.submitBehavior.form, customSubmit.internals_.form,
'behavior.form should match elementInternals.form');
const formData = await submitAndCollectFormData(customSubmit);
assert_equals(formData.get('field'), 'value',
'Form should be submitted via form attribute association');
}, 'Custom element with form attribute submits associated form');
promise_test(async t => {
t.add_cleanup(reset);
form.setAttribute('action', '/common/blank.html');
customSubmit.submitBehavior.formTarget = 'submit-target';
const win = await submitIntoIframe(customSubmit);
assert_true(win.location.href.includes('/common/blank.html'),
'Form should be submitted into the frame specified by behavior formTarget');
}, 'formTarget on HTMLSubmitButtonBehavior overrides form target');
</script>