summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/custom-elements/form-associated
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /testing/web-platform/tests/custom-elements/form-associated
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/custom-elements/form-associated')
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-NotSupportedError.html24
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-form.html61
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-labels.html67
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble-ref.html28
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble.html29
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-setFormValue.html587
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-target-element-is-held-strongly.html26
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/ElementInternals-validation.html356
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/fieldset-elements.html50
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/form-associated-callback.html249
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/form-disabled-callback.html114
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/form-elements-namedItem.html66
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/form-reset-callback.html58
-rw-r--r--testing/web-platform/tests/custom-elements/form-associated/label-delegatesFocus.html38
14 files changed, 1753 insertions, 0 deletions
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-NotSupportedError.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-NotSupportedError.html
new file mode 100644
index 0000000000..51be7183c1
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-NotSupportedError.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+test(() => {
+ class NotFormAssociatedElement extends HTMLElement {}
+ customElements.define('my-element1', NotFormAssociatedElement);
+ const element = new NotFormAssociatedElement();
+ const i = element.attachInternals();
+
+ assert_throws_dom('NotSupportedError', () => i.setFormValue(''));
+ assert_throws_dom('NotSupportedError', () => i.form);
+ assert_throws_dom('NotSupportedError', () => i.setValidity({}));
+ assert_throws_dom('NotSupportedError', () => i.willValidate);
+ assert_throws_dom('NotSupportedError', () => i.validity);
+ assert_throws_dom('NotSupportedError', () => i.validationMessage);
+ assert_throws_dom('NotSupportedError', () => i.checkValidity());
+ assert_throws_dom('NotSupportedError', () => i.reportValidity());
+ assert_throws_dom('NotSupportedError', () => i.labels);
+}, 'Form-related operations and attributes should throw NotSupportedErrors' +
+ ' for non-form-associated custom elements.');
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-form.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-form.html
new file mode 100644
index 0000000000..fd9363b450
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-form.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<title>form attribute of ElementInternals</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+customElements.define('custom-input-parser', class extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+});
+</script>
+
+<form id="custom-form">
+ <custom-input-parser id="custom-1"></custom-input-parser>
+ <custom-input-upgrade id="custom-2"></custom-input-upgrade>
+</form>
+<custom-input-parser id="custom-3" form="custom-form"></custom-input-parser>
+<custom-input-upgrade id="custom-4" form="custom-form"></custom-input-upgrade>
+<custom-input-upgrade id="custom-5"></custom-input-upgrade>
+
+<script>
+test(() => {
+ const form = document.forms[0];
+ assert_equals(document.getElementById("custom-1").i.form, form);
+ assert_equals(document.getElementById("custom-3").i.form, form);
+
+ // Test upgrading.
+ customElements.define('custom-input-upgrade', class extends HTMLElement {
+ static get formAssociated() { return true; }
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+ });
+ assert_equals(document.getElementById("custom-2").i.form, form);
+ assert_equals(document.getElementById("custom-4").i.form, form);
+
+ // Test changing name attribuate.
+ let custom5 = document.getElementById("custom-5");
+ assert_equals(custom5.i.form, null);
+ custom5.setAttribute("form", "custom-form");
+ assert_equals(custom5.i.form, form);
+}, "ElementInternals.form should return the target element's form owner");
+
+test(() => {
+ class NotFormAssociatedElement extends HTMLElement {}
+ customElements.define('not-form-associated-element', NotFormAssociatedElement);
+ const element = new NotFormAssociatedElement();
+ const i = element.attachInternals();
+ assert_throws_dom('NotSupportedError', () => i.form);
+}, "ElementInternals.form should throws NotSupportedError if the target element is not a form-associated custom element");
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-labels.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-labels.html
new file mode 100644
index 0000000000..b27be5f2fc
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-labels.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<title>labels attribute of ElementInternals, and label association</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id="container"></div>
+<script>
+class MyControl extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+}
+customElements.define('my-control', MyControl);
+const container = document.querySelector('#container');
+
+test(() => {
+ container.innerHTML = '<label><span><my-control></my-control></span></label>';
+ let label = container.querySelector('label');
+ let control = container.querySelector('my-control');
+ assert_equals(label.control, control);
+ assert_true(control.i.labels instanceof NodeList);
+ assert_array_equals(control.i.labels, [label]);
+
+ container.innerHTML = '<label for="mc"></label><form><my-control id="mc"></my-control></form>';
+ label = container.querySelector('label');
+ control = container.querySelector('my-control');
+ assert_equals(label.control, control);
+ assert_equals(label.form, control.i.form);
+ assert_array_equals(control.i.labels, [label]);
+
+ container.innerHTML = '<label for="mc"></label><label for="mc"><my-control id="mc">';
+ const labels = container.querySelectorAll('label');
+ control = container.querySelector('my-control');
+ assert_array_equals(control.i.labels, labels);
+
+ container.innerHTML = '<my-control></my-control>';
+ control = container.querySelector('my-control');
+ assert_array_equals(control.i.labels, []);
+
+ container.innerHTML = '<label><x-foo></x-foo></label>';
+ label = container.querySelector('label');
+ assert_equals(label.control, null);
+}, 'LABEL association');
+
+test(() => {
+ container.innerHTML = '<label for="mc"></label><form><my-control id="mc"></my-control></form>';
+ const control = container.querySelector('my-control');
+ let clickCount = 0;
+ control.addEventListener('click', e => { ++clickCount; });
+ container.querySelector('label').click();
+ assert_equals(clickCount, 1);
+}, 'LABEL click');
+
+test(() => {
+ class NotFormAssociatedElement extends HTMLElement {}
+ customElements.define('not-form-associated-element', NotFormAssociatedElement);
+ const element = new NotFormAssociatedElement();
+ const i = element.attachInternals();
+ assert_throws_dom('NotSupportedError', () => i.labels);
+}, "ElementInternals.labels should throw NotSupportedError if the target element is not a form-associated custom element");
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble-ref.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble-ref.html
new file mode 100644
index 0000000000..54a438245c
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble-ref.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<title>Both focusable and unfocusable custom elements can show validation bubbles</title>
+<link rel=help href=https://html.spec.whatwg.org/C/#report-validity-steps>
+<focusable-custom-element tabindex="0"></focusable-custom-element>
+<script>
+class FocusableCustomElement extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: 'open'});
+ this.shadowRoot.innerHTML = '<input>';
+ this.elementInternals = this.attachInternals();
+ const validationAnchor = this.shadowRoot.querySelector('input');
+ this.elementInternals.setValidity({valueMissing: true}, 'value missing', validationAnchor);
+ }
+
+ static get formAssociated() {
+ return true;
+ }
+
+ reportValidity() {
+ this.elementInternals.reportValidity();
+ }
+}
+
+customElements.define('focusable-custom-element', FocusableCustomElement);
+
+document.querySelector('focusable-custom-element').reportValidity();
+</script>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble.html
new file mode 100644
index 0000000000..6ada4b473a
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-reportValidity-bubble.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<title>Both focusable and unfocusable custom elements can show validation bubbles</title>
+<link rel=match href=ElementInternals-reportValidity-bubble-ref.html>
+<link rel=help href=https://html.spec.whatwg.org/C/#report-validity-steps>
+<unfocusable-custom-element></unfocusable-custom-element>
+<script>
+class UnfocusableCustomElement extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: 'open'});
+ this.shadowRoot.innerHTML = '<input>';
+ this.elementInternals = this.attachInternals();
+ const validationAnchor = this.shadowRoot.querySelector('input');
+ this.elementInternals.setValidity({valueMissing: true}, 'value missing', validationAnchor);
+ }
+
+ static get formAssociated() {
+ return true;
+ }
+
+ reportValidity() {
+ this.elementInternals.reportValidity();
+ }
+}
+
+customElements.define('unfocusable-custom-element', UnfocusableCustomElement);
+
+document.querySelector('unfocusable-custom-element').reportValidity();
+</script>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-setFormValue.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-setFormValue.html
new file mode 100644
index 0000000000..8a13973f08
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-setFormValue.html
@@ -0,0 +1,587 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id="container"></div>
+<script>
+class MyControl extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ this.value_ = '';
+ }
+
+ get value() {
+ return this.value_;
+ }
+ set value(v) {
+ this.internals_.setFormValue(v);
+ this.value_ = v;
+ }
+ setValues(nameValues) {
+ const formData = new FormData();
+ for (let p of nameValues) {
+ formData.append(p[0], p[1]);
+ }
+ this.internals_.setFormValue(formData);
+ }
+}
+customElements.define('my-control', MyControl);
+const $ = document.querySelector.bind(document);
+
+function submitPromise(t, extractFromIframe) {
+ if (!extractFromIframe) {
+ extractFromIframe = (iframe) => iframe.contentWindow.location.search;
+ }
+ return new Promise((resolve, reject) => {
+ const iframe = $('iframe');
+ iframe.onload = () => resolve(extractFromIframe(iframe));
+ iframe.onerror = () => reject(new Error('iframe onerror fired'));
+ $('form').submit();
+ });
+}
+
+function testSerializedEntry({name, value, expected, description}) {
+ // urlencoded
+ {
+ const {name: expectedName, value: expectedValue} = expected.urlencoded;
+ promise_test(async t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<my-control></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ if (name !== undefined) {
+ $('my-control').setAttribute("name", name);
+ }
+ if (Array.isArray(value)) {
+ $('my-control').setValues(value);
+ } else {
+ $('my-control').value = value;
+ }
+ const query = await submitPromise(t);
+ assert_equals(query, `?${expectedName}=${expectedValue}`);
+ }, `${description} (urlencoded)`);
+ }
+
+ // formdata
+ {
+ const {name: expectedName, filename: expectedFilename, value: expectedValue} = expected.formdata;
+ promise_test(async t => {
+ $('#container').innerHTML =
+ '<form action="/FileAPI/file/resources/echo-content-escaped.py" method="post" enctype="multipart/form-data" target="if1">' +
+ '<my-control></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ if (name !== undefined) {
+ $('my-control').setAttribute("name", name);
+ }
+ if (Array.isArray(value)) {
+ $('my-control').setValues(value);
+ } else {
+ $('my-control').value = value;
+ }
+ const escaped = await submitPromise(t, iframe => iframe.contentDocument.body.textContent);
+ const formdata = escaped
+ .replace(/\r\n?|\n/g, "\r\n")
+ .replace(
+ /\\x[0-9A-Fa-f]{2}/g,
+ escape => String.fromCodePoint(parseInt(escape.substring(2), 16))
+ );
+ const boundary = formdata.split("\r\n")[0];
+ const expected = [
+ boundary,
+ ...(() => {
+ if (expectedFilename === undefined) {
+ return [`Content-Disposition: form-data; name="${expectedName}"`];
+ } else {
+ return [
+ `Content-Disposition: form-data; name="${expectedName}"; filename="${expectedFilename}"`,
+ "Content-Type: text/plain"
+ ];
+ }
+ })(),
+ "",
+ expectedValue,
+ boundary + "--",
+ ""
+ ].join("\r\n");
+ assert_equals(formdata, expected);
+ }, `${description} (formdata)`);
+ }
+}
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ return submitPromise(t).then(query => {
+ assert_equals(query, '?name-pd1=value-pd1');
+ });
+}, 'Single value - name is missing');
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control name=""></my-control>' +
+ '<input name=name-pd2 value="value-pd2">' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ $('my-control').value = 'value-ce1';
+ return submitPromise(t).then(query => {
+ assert_equals(query, '?name-pd1=value-pd1&name-pd2=value-pd2');
+ });
+}, 'Single value - empty name exists');
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1" accept-charset=utf-8>' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control name="name-ce1"></my-control>' +
+ '<my-control name="name-usv"></my-control>' +
+ '<my-control name="name-file"></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ const USV_INPUT = 'abc\uDC00\uD800def';
+ const USV_OUTPUT = 'abc\uFFFD\uFFFDdef';
+ const FILE_NAME = 'test_file.txt';
+ $('[name=name-usv]').value = USV_INPUT;
+ $('[name=name-file]').value = new File(['file content'], FILE_NAME);
+ return submitPromise(t).then(query => {
+ assert_equals(query, `?name-pd1=value-pd1&name-usv=${encodeURIComponent(USV_OUTPUT)}&name-file=${FILE_NAME}`);
+ });
+}, 'Single value - Non-empty name exists');
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control name="name-ce1"></my-control>' +
+ '<my-control name="name-ce2"></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ $('my-control').value = null;
+ return submitPromise(t).then(query => {
+ assert_equals(query, '?name-pd1=value-pd1');
+ });
+}, 'Null value should submit nothing');
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control name=name-ce1></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ $('my-control').value = 'value-ce1';
+ $('my-control').setValues([]);
+ $('my-control').setValues([['sub1', 'subvalue1'],
+ ['sub2', 'subvalue2'],
+ ['sub2', 'subvalue3']]);
+ return submitPromise(t).then(query => {
+ assert_equals(query, '?name-pd1=value-pd1&sub1=subvalue1&sub2=subvalue2&sub2=subvalue3');
+ });
+}, 'Multiple values - name content attribute is ignored');
+
+promise_test(t => {
+ $('#container').innerHTML = '<form action="/common/blank.html" target="if1">' +
+ '<input name=name-pd1 value="value-pd1">' +
+ '<my-control name=name-ce1></my-control>' +
+ '</form>' +
+ '<iframe name="if1"></iframe>';
+ $('my-control').value = 'value-ce1';
+ $('my-control').setValues([]);
+ return submitPromise(t).then(query => {
+ assert_equals(query, '?name-pd1=value-pd1');
+ });
+}, 'setFormValue with an empty FormData should submit nothing');
+
+testSerializedEntry({
+ name: 'a\nb',
+ value: 'c',
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\n in name'
+});
+
+testSerializedEntry({
+ name: 'a\rb',
+ value: 'c',
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\r in name'
+});
+
+testSerializedEntry({
+ name: 'a\r\nb',
+ value: 'c',
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\r\\n in name'
+});
+
+testSerializedEntry({
+ name: 'a\n\rb',
+ value: 'c',
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0A%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0A%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\n\\r in name'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: 'b\nc',
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\n in value'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: 'b\rc',
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\r in value'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: 'b\r\nc',
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\r\\n in value'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: 'b\n\rc',
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0A%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\n\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\n\\r in value'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: new File([], "b\nc", {type: "text/plain"}),
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0Ac',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\n in filename'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: new File([], "b\rc", {type: "text/plain"}),
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0Dc',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\r in filename'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: new File([], "b\r\nc", {type: "text/plain"}),
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0D%0Ac',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\r\\n in filename'
+});
+
+testSerializedEntry({
+ name: 'a',
+ value: new File([], "b\n\rc", {type: "text/plain"}),
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0A%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0A%0Dc',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\n\\r in filename'
+});
+
+testSerializedEntry({
+ value: [['a\nb', 'c']],
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\n in FormData name'
+});
+
+testSerializedEntry({
+ value: [['a\rb', 'c']],
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\r in FormData name'
+});
+
+testSerializedEntry({
+ value: [['a\r\nb', 'c']],
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\r\\n in FormData name'
+});
+
+testSerializedEntry({
+ value: [['a\n\rb', 'c']],
+ expected: {
+ urlencoded: {
+ name: 'a%0D%0A%0D%0Ab',
+ value: 'c'
+ },
+ formdata: {
+ name: 'a%0D%0A%0D%0Ab',
+ value: 'c'
+ }
+ },
+ description: 'Newline normalization - \\n\\r in FormData name'
+});
+
+testSerializedEntry({
+ value: [['a', 'b\nc']],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\n in FormData value'
+});
+
+testSerializedEntry({
+ value: [['a', 'b\rc']],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\r in FormData value'
+});
+
+testSerializedEntry({
+ value: [['a', 'b\r\nc']],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\r\\n in FormData value'
+});
+
+testSerializedEntry({
+ value: [['a', 'b\n\rc']],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0A%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ value: 'b\r\n\r\nc'
+ }
+ },
+ description: 'Newline normalization - \\n\\r in FormData value'
+});
+
+testSerializedEntry({
+ value: [['a', new File([], 'b\nc', {type: "text/plain"})]],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0Ac',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\n in FormData filename'
+});
+
+testSerializedEntry({
+ value: [['a', new File([], 'b\rc', {type: "text/plain"})]],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0Dc',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\r in FormData filename'
+});
+
+testSerializedEntry({
+ value: [['a', new File([], 'b\r\nc', {type: "text/plain"})]],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0D%0Ac',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\r\\n in FormData filename'
+});
+
+testSerializedEntry({
+ value: [['a', new File([], 'b\n\rc', {type: "text/plain"})]],
+ expected: {
+ urlencoded: {
+ name: 'a',
+ value: 'b%0D%0A%0D%0Ac'
+ },
+ formdata: {
+ name: 'a',
+ filename: 'b%0A%0Dc',
+ value: ''
+ }
+ },
+ description: 'Newline normalization - \\n\\r in FormData filename'
+});
+
+test(() => {
+ class NotFormAssociatedElement extends HTMLElement {}
+ customElements.define('not-form-associated-element', NotFormAssociatedElement);
+ const element = new NotFormAssociatedElement();
+ const i = element.attachInternals();
+ assert_throws_dom('NotSupportedError', () => i.setFormValue("test"));
+}, "ElementInternals.setFormValue() should throw NotSupportedError if the target element is not a form-associated custom element");
+
+</script>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-target-element-is-held-strongly.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-target-element-is-held-strongly.html
new file mode 100644
index 0000000000..a747c04c5c
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-target-element-is-held-strongly.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<title>Target element of ElementsInternals is held strongly and doesn't get GCed if there are no other references</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/gc.js"></script>
+
+<script>
+customElements.define("x-foo", class extends HTMLElement {});
+
+promise_test(async t => {
+ const elementInternals = [];
+
+ for (let i = 0; i < 1e5; i++) {
+ const targetElement = document.createElement("x-foo");
+ targetElement.attachShadow({ mode: "open" });
+ elementInternals.push(targetElement.attachInternals());
+ }
+
+ await garbageCollect();
+ await new Promise(r => t.step_timeout(r, 100));
+
+ const allShadowRootsAreAlive = elementInternals.every(eI => eI.shadowRoot instanceof ShadowRoot);
+ assert_true(allShadowRootsAreAlive);
+});
+</script>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-validation.html b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-validation.html
new file mode 100644
index 0000000000..8114a5dbd8
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/ElementInternals-validation.html
@@ -0,0 +1,356 @@
+<!DOCTYPE html>
+<title>Form validation features of ElementInternals, and :valid :invalid pseudo classes</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id="container"></div>
+<script>
+class MyControl extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+}
+customElements.define('my-control', MyControl);
+
+class NotFormAssociatedElement extends HTMLElement {
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+}
+customElements.define('not-form-associated-element', NotFormAssociatedElement);
+
+test(() => {
+ const control = new MyControl();
+ assert_true(control.i.willValidate, 'default value is true');
+
+ const datalist = document.createElement('datalist');
+ datalist.appendChild(control);
+ assert_false(control.i.willValidate, 'false in DATALIST');
+ datalist.removeChild(control);
+ assert_true(control.i.willValidate, 'remove from DATALIST');
+
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ fieldset.appendChild(control);
+ assert_false(control.i.willValidate, 'append to disabled FIELDSET');
+ fieldset.disabled = false;
+ assert_true(control.i.willValidate, 'FIELDSET becomes enabled');
+ fieldset.disabled = true;
+ assert_false(control.i.willValidate, 'FIELDSET becomes disabled');
+ fieldset.removeChild(control);
+ assert_true(control.i.willValidate, 'remove from disabled FIELDSET');
+
+ control.setAttribute('disabled', '');
+ assert_false(control.i.willValidate, 'with disabled attribute');
+ control.removeAttribute('disabled');
+ assert_true(control.i.willValidate, 'without disabled attribute');
+
+ control.setAttribute('readonly', '');
+ assert_false(control.i.willValidate, 'with readonly attribute');
+ control.removeAttribute('readonly');
+ assert_true(control.i.willValidate, 'without readonly attribute');
+}, 'willValidate');
+
+test(() => {
+ const container = document.getElementById("container");
+ container.innerHTML = '<will-be-defined></will-be-defined>' +
+ '<will-be-defined disabled></will-be-defined>' +
+ '<will-be-defined readonly></will-be-defined>' +
+ '<datalist><will-be-defined></will-be-defined></datalist>' +
+ '<fieldset disabled><will-be-defined></will-be-defined></fieldset>';
+
+ class WillBeDefined extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ }
+ get i() { return this.internals_; }
+ }
+ customElements.define('will-be-defined', WillBeDefined);
+ customElements.upgrade(container);
+
+ const controls = document.querySelectorAll('will-be-defined');
+ assert_true(controls[0].i.willValidate, 'default value');
+ assert_false(controls[1].i.willValidate, 'with disabled attribute');
+ assert_false(controls[2].i.willValidate, 'with readOnly attribute');
+ assert_false(controls[3].i.willValidate, 'in datalist');
+ assert_false(controls[4].i.willValidate, 'in disabled fieldset');
+}, 'willValidate after upgrade');
+
+test(t => {
+ const control = document.createElement('will-be-defined-2');
+
+ customElements.define('will-be-defined-2', class extends HTMLElement {
+ static get formAssociated() { return true; }
+ });
+
+ container.append(control);
+ t.add_cleanup(() => { container.innerHTML = ''; });
+
+ const i = control.attachInternals();
+ assert_true(i.willValidate);
+}, 'willValidate after upgrade (document.createElement)');
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.willValidate);
+}, "willValidate should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.validity);
+}, "validity should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.validationMessage);
+}, "validationMessage should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.setValidity());
+}, "setValidity() should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const control = document.createElement('my-control');
+ const validity = control.i.validity;
+ assert_false(validity.valueMissing, 'default valueMissing');
+ assert_false(validity.typeMismatch, 'default typeMismatch');
+ assert_false(validity.patternMismatch, 'default patternMismatch');
+ assert_false(validity.tooLong, 'default tooLong');
+ assert_false(validity.tooShort, 'default tooShort');
+ assert_false(validity.rangeUnderflow, 'default rangeUnderflow');
+ assert_false(validity.rangeOverflow, 'default rangeOverflow');
+ assert_false(validity.stepMismatch, 'default stepMismatch');
+ assert_false(validity.badInput, 'default badInput');
+ assert_false(validity.customError, 'default customError');
+ assert_true(validity.valid, 'default valid');
+
+ control.i.setValidity({valueMissing: true}, 'valueMissing message');
+ assert_true(validity.valueMissing);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'valueMissing message');
+
+ control.i.setValidity({typeMismatch: true}, 'typeMismatch message');
+ assert_true(validity.typeMismatch);
+ assert_false(validity.valueMissing);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'typeMismatch message');
+
+ control.i.setValidity({patternMismatch: true}, 'patternMismatch message');
+ assert_true(validity.patternMismatch);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'patternMismatch message');
+
+ control.i.setValidity({tooLong: true}, 'tooLong message');
+ assert_true(validity.tooLong);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'tooLong message');
+
+ control.i.setValidity({tooShort: true}, 'tooShort message');
+ assert_true(validity.tooShort);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'tooShort message');
+
+ control.i.setValidity({rangeUnderflow: true}, 'rangeUnderflow message');
+ assert_true(validity.rangeUnderflow);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'rangeUnderflow message');
+
+ control.i.setValidity({rangeOverflow: true}, 'rangeOverflow message');
+ assert_true(validity.rangeOverflow);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'rangeOverflow message');
+
+ control.i.setValidity({stepMismatch: true}, 'stepMismatch message');
+ assert_true(validity.stepMismatch);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'stepMismatch message');
+
+ control.i.setValidity({badInput: true}, 'badInput message');
+ assert_true(validity.badInput);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'badInput message');
+
+ control.i.setValidity({customError: true}, 'customError message');
+ assert_true(validity.customError, 'customError should be true.');
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'customError message');
+
+ // Set multiple flags
+ control.i.setValidity({badInput: true, customError: true}, 'multiple errors');
+ assert_true(validity.badInput);
+ assert_true(validity.customError);
+ assert_false(validity.valid);
+ assert_equals(control.i.validationMessage, 'multiple errors');
+
+ // Clear flags
+ control.i.setValidity({}, 'unnecessary message');
+ assert_false(validity.badInput);
+ assert_false(validity.customError);
+ assert_true(validity.valid);
+ assert_equals(control.i.validationMessage, '');
+
+ assert_throws_js(TypeError, () => { control.i.setValidity({valueMissing: true}); },
+ 'setValidity() requires the second argument if the first argument contains true');
+}, 'validity and setValidity()');
+
+test(() => {
+ document.body.insertAdjacentHTML('afterbegin', '<my-control><light-child></my-control>');
+ let control = document.body.firstChild;
+ const flags = {valueMissing: true};
+ const m = 'non-empty message';
+
+ assert_throws_dom('NotFoundError', () => {
+ control.i.setValidity(flags, m, document.body);
+ }, 'Not a descendant');
+
+ assert_throws_dom('NotFoundError', () => {
+ control.i.setValidity(flags, m, control);
+ }, 'Self');
+
+ let notHTMLElement = document.createElementNS('some-random-namespace', 'foo');
+ control.appendChild(notHTMLElement);
+ assert_throws_js(TypeError, () => {
+ control.i.setValidity(flags, m, notHTMLElement);
+ }, 'Not a HTMLElement');
+ notHTMLElement.remove();
+
+ // A descendant
+ control.i.setValidity(flags, m, control.querySelector('light-child'));
+
+ // An element in the direct shadow tree
+ let shadow1 = control.attachShadow({mode: 'open'});
+ let shadowChild = document.createElement('div');
+ shadow1.appendChild(shadowChild);
+ control.i.setValidity(flags, m, shadowChild);
+
+ // An element in an nested shadow trees
+ let shadow2 = shadowChild.attachShadow({mode: 'closed'});
+ let nestedShadowChild = document.createElement('span');
+ shadow2.appendChild(nestedShadowChild);
+ control.i.setValidity(flags, m, nestedShadowChild);
+}, '"anchor" argument of setValidity()');
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.checkValidity());
+}, "checkValidity() should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const control = document.createElement('my-control');
+ let invalidCount = 0;
+ control.addEventListener('invalid', e => {
+ assert_equals(e.target, control);
+ assert_true(e.cancelable);
+ ++invalidCount;
+ });
+
+ assert_true(control.i.checkValidity(), 'default state');
+ assert_equals(invalidCount, 0);
+
+ control.i.setValidity({customError:true}, 'foo');
+ assert_false(control.i.checkValidity());
+ assert_equals(invalidCount, 1);
+}, 'checkValidity()');
+
+test(() => {
+ const element = new NotFormAssociatedElement();
+ assert_throws_dom('NotSupportedError', () => element.i.reportValidity());
+}, "reportValidity() should throw NotSupportedError if the target element is not a form-associated custom element");
+
+test(() => {
+ const control = document.createElement('my-control');
+ document.body.appendChild(control);
+ control.tabIndex = 0;
+ let invalidCount = 0;
+ control.addEventListener('invalid', e => {
+ assert_equals(e.target, control);
+ assert_true(e.cancelable);
+ ++invalidCount;
+ });
+
+ assert_true(control.i.reportValidity(), 'default state');
+ assert_equals(invalidCount, 0);
+
+ control.i.setValidity({customError:true}, 'foo');
+ assert_false(control.i.reportValidity());
+ assert_equals(invalidCount, 1);
+
+ control.blur();
+ control.addEventListener('invalid', e => e.preventDefault());
+ assert_false(control.i.reportValidity());
+}, 'reportValidity()');
+
+test(() => {
+ const container = document.getElementById('container');
+ container.innerHTML = '<form><input type=submit><my-control>';
+ const form = container.querySelector('form');
+ const control = container.querySelector('my-control');
+ control.tabIndex = 0;
+ let invalidCount = 0;
+ control.addEventListener('invalid', e => { ++invalidCount; });
+
+ assert_true(control.i.checkValidity());
+ assert_true(form.checkValidity());
+ control.i.setValidity({valueMissing: true}, 'Please fill out this field');
+ assert_false(form.checkValidity());
+ assert_equals(invalidCount, 1);
+
+ assert_false(form.reportValidity());
+ assert_equals(invalidCount, 2);
+
+ container.querySelector('input[type=submit]').click();
+ assert_equals(invalidCount, 3);
+}, 'Custom control affects validation at the owner form');
+
+function isValidAccordingToCSS(element, comment) {
+ assert_true(element.matches(':valid'), comment ? (comment + ' - :valid') : undefined);
+ assert_false(element.matches(':invalid'), comment ? (comment + ' - :invalid') : undefined);
+}
+
+function isInvalidAccordingToCSS(element, comment) {
+ assert_false(element.matches(':valid'), comment ? (comment + ' - :valid') : undefined);
+ assert_true(element.matches(':invalid'), comment ? (comment + ' - :invalid') : undefined);
+}
+
+test(() => {
+ const container = document.getElementById('container');
+ container.innerHTML = '<form><fieldset><my-control>';
+ const form = container.querySelector('form');
+ const fieldset = container.querySelector('fieldset');
+ const control = container.querySelector('my-control');
+
+ isValidAccordingToCSS(control);
+ isValidAccordingToCSS(form);
+ isValidAccordingToCSS(fieldset);
+
+ control.i.setValidity({typeMismatch: true}, 'Invalid format');
+ isInvalidAccordingToCSS(control);
+ isInvalidAccordingToCSS(form);
+ isInvalidAccordingToCSS(fieldset);
+
+ control.remove();
+ isInvalidAccordingToCSS(control);
+ isValidAccordingToCSS(form);
+ isValidAccordingToCSS(fieldset);
+
+ fieldset.appendChild(control);
+ isInvalidAccordingToCSS(form);
+ isInvalidAccordingToCSS(fieldset);
+
+ control.i.setValidity({});
+ isValidAccordingToCSS(control);
+ isValidAccordingToCSS(form);
+ isValidAccordingToCSS(fieldset);
+}, 'Custom control affects :valid :invalid for FORM and FIELDSET');
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/fieldset-elements.html b/testing/web-platform/tests/custom-elements/form-associated/fieldset-elements.html
new file mode 100644
index 0000000000..dc42a57b70
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/fieldset-elements.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+customElements.define('custom-input-parser', class extends HTMLElement {
+ static get formAssociated() {return true;}
+});
+</script>
+
+<form>
+ <fieldset id="fs_outer">
+ <custom-input-parser name="custom-1"></custom-input>
+ <custom-input-upgrade name="custom-2"></custom-input>
+ <fieldset id="fs_inner">
+ <custom-input-parser name="custom-3"></custom-input>
+ <custom-input-upgrade name="custom-4"></custom-input>
+ <uncustom-input></custom-input>
+ </fieldset>
+ </fieldset>
+ <custom-input-parser name="custom-5"></custom-input>
+ <custom-input-upgrade name="custom-6"></custom-input>
+</form>
+
+<script>
+test(() => {
+ const formElements = document.forms[0].elements;
+ const fs_outer = document.getElementById("fs_outer");
+ const fs_inner = document.getElementById("fs_inner");
+
+ assert_array_equals(fs_inner.elements, [formElements['custom-3']],
+ "The items in the collection must be children of the inner fieldset element.");
+ assert_array_equals(fs_outer.elements, [formElements['custom-1'], fs_inner, formElements['custom-3']],
+ "The items in the collection must be descendants of the outer fieldset element.");
+
+ customElements.define('custom-input-upgrade', class extends HTMLElement {
+ static get formAssociated() {return true;}
+ });
+
+ assert_array_equals(fs_inner.elements, [formElements['custom-3'], formElements['custom-4']],
+ "The items in the collection must be children of the inner fieldset element.");
+ assert_array_equals(fs_outer.elements,
+ [formElements['custom-1'], formElements['custom-2'], fs_inner, formElements['custom-3'], formElements['custom-4']],
+ "The items in the collection must be descendants of the outer fieldset element.");
+
+}, 'Form associated custom elements should work with fieldset.elements');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/form-associated-callback.html b/testing/web-platform/tests/custom-elements/form-associated/form-associated-callback.html
new file mode 100644
index 0000000000..7feec50fef
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/form-associated-callback.html
@@ -0,0 +1,249 @@
+<!DOCTYPE html>
+<title>formAssociatedCallback, and form IDL attribute of ElementInternals</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+class PreDefined extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ this.formHistory_ = [];
+ }
+
+ formAssociatedCallback(nullableForm) {
+ this.formHistory_.push(nullableForm);
+ }
+ formHistory() {
+ return this.formHistory_;
+ }
+
+ get form() {
+ return this.internals_.form;
+ }
+}
+customElements.define('pre-defined', PreDefined);
+</script>
+<div id="container">
+
+<fieldset id="fs1">
+<form id="form1">
+<input>
+<pre-defined id="pd1"></pre-defined>
+<select></select>
+</form>
+</fieldset>
+
+<fieldset id="fs2">
+<pre-defined id="pd2" form="form2"></pre-defined>
+<form id="form2">
+<input>
+<select></select>
+</form>
+</fieldset>
+<pre-defined id="pd3" form="form2"></pre-defined>
+
+<table>
+<fieldset id="fs3">
+<form id="form3">
+<tr><td><select></select></tr>
+<tr><td><pre-defined id="pd4"></pre-defined></tr>
+<tr><td><input></tr>
+</form> <!-- The end tag is bogus. -->
+</fieldset> <!-- The end tag is bogus. -->
+<table>
+
+</div>
+
+<script>
+const $ = document.querySelector.bind(document);
+
+test(() => {
+ let controls = $('#form1').elements;
+ assert_equals(controls.length, 3);
+ assert_equals(controls[1], $('#pd1'), 'form.elements');
+ assert_equals($('#pd1').form, $('#form1'));
+ assert_array_equals($('#pd1').formHistory(), [$('#form1')]);
+ assert_equals($('#fs1').elements[1], $('#pd1'), 'fieldset.elements');
+
+ controls = $('#form2').elements;
+ assert_equals(controls.length, 4);
+ assert_equals(controls[0], $('#pd2'), 'form.elements');
+ assert_equals(controls[3], $('#pd3'));
+ assert_equals($('#pd2').form, $('#form2'));
+ assert_equals($('#pd3').form, $('#form2'));
+ assert_array_equals($('#pd2').formHistory(), [$('#form2')]);
+ assert_array_equals($('#pd3').formHistory(), [$('#form2')]);
+ controls = $('#fs2').elements;
+ assert_equals(controls.length, 3);
+ assert_equals(controls[0], $('#pd2'), 'fieldset.elements');
+
+ controls = $('#form3').elements;
+ assert_equals(controls.length, 2);
+ assert_not_equals(controls[1], $('#pd4'));
+ assert_equals($('#fs3').elements.length, 0);
+}, 'Associate by parser, customized at element creation');
+
+test(() => {
+ $('#container').innerHTML = '<fieldset id="fs1"><form id="form1"><input><will-be-defined id="wbd1">' +
+ '</will-be-defined><select></select></form></fieldset>' +
+ '<fieldset id="fs2"><will-be-defined id="wbd2" form="form2"></will-be-defined>' +
+ '<form id="form2"></form></fieldset><will-be-defined id="wbd3" form="form2"></will-be-defined>';
+ let controls = $('#form1').elements;
+ assert_equals(controls.length, 2);
+ assert_not_equals(controls[1], $('#wbd1'));
+ controls = $('#fs1').elements;
+ assert_equals(controls.length, 2);
+ assert_not_equals(controls[1], $('#wbd1'));
+
+ assert_equals($('#form2').elements.length, 0);
+ assert_equals($('#fs2').elements.length, 0);
+
+ class WillBeDefined extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ this.formHistory_ = [];
+ }
+
+ formAssociatedCallback(nullableForm) {
+ this.formHistory_.push(nullableForm);
+ }
+ formHistory() {
+ return this.formHistory_;
+ }
+
+ get form() {
+ return this.internals_.form;
+ }
+ }
+ customElements.define('will-be-defined', WillBeDefined);
+ customElements.upgrade(container);
+
+ controls = $('#form1').elements;
+ assert_equals(controls.length, 3, 'form.elements.length');
+ assert_equals(controls[1], $('#wbd1'));
+ assert_equals($('#wbd1').form, $('#form1'));
+ controls = $('#fs1').elements;
+ assert_equals(controls.length, 3, 'fieldset.elements.length');
+ assert_equals(controls[1], $('#wbd1'));
+
+ controls = $('#form2').elements;
+ assert_equals($('#wbd2').form, $('#form2'));
+ assert_equals($('#wbd3').form, $('#form2'));
+ assert_array_equals($('#wbd2').formHistory(), [$('#form2')]);
+ assert_array_equals($('#wbd3').formHistory(), [$('#form2')]);
+ assert_equals(controls.length, 2, 'form.elements.length');
+ assert_equals(controls[0], $('#wbd2'));
+ assert_equals(controls[1], $('#wbd3'));
+ controls = $('#fs2').elements;
+ assert_equals(controls.length, 1, 'fieldset.elements.length');
+ assert_equals(controls[0], $('#wbd2'));
+}, 'Parsed, connected, then upgraded');
+
+test(() => {
+ $('#container').innerHTML = '<fieldset id="fs1"><form id="form1"><input><pre-defined id="pd1">' +
+ '</pre-defined><select></select></form></fieldset>' +
+ '<fieldset id="fs2"><pre-defined id="pd2" form="form2"></pre-defined>' +
+ '<form id="form2"></form></fieldset><pre-defined id="pd3" form="form2"></pre-defined>';
+
+ const pd1 = $('#pd1');
+ assert_equals($('#form1').elements.length, 3, 'form.elements.length before removal');
+ assert_equals($('#fs1').elements.length, 3, 'fildset.elements.length before removal');
+ pd1.remove();
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), [$('#form1'), null]);
+ assert_equals($('#form1').elements.length, 2, 'form.elements.length after removal');
+ assert_equals($('#fs1').elements.length, 2, 'fildset.elements.length after removal');
+
+ const pd2 = $('#pd2');
+ const pd3 = $('#pd3');
+ assert_equals($('#form2').elements.length, 2, 'form.elements.length before removal');
+ assert_equals($('#fs2').elements.length, 1, 'fieldset.elements.length before removal');
+ pd2.remove();
+ pd3.remove();
+ assert_equals(pd2.form, null);
+ assert_equals(pd3.form, null);
+ assert_array_equals(pd2.formHistory(), [$('#form2'), null]);
+ assert_array_equals(pd3.formHistory(), [$('#form2'), null]);
+ assert_equals($('#form2').elements.length, 0, 'form.elements.length after removal');
+ assert_equals($('#fs2').elements.length, 0, 'fieldset.elements.length after removal');
+}, 'Disassociation');
+
+test(() => {
+ $('#container').innerHTML = '<form id="form1"></form>' +
+ '<pre-defined id="pd1"></pre-defined><form id="form2">' +
+ '</form><form id="form3"></form>';
+ const pd1 = $('#pd1');
+ const form1 = $('#form1');
+ const form2 = $('#form2');
+ const form3 = $('#form3');
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), []);
+
+ pd1.setAttribute('form', 'form1');
+ assert_equals(pd1.form, form1);
+ assert_array_equals(pd1.formHistory(), [form1]);
+
+ pd1.setAttribute('form', 'invalid');
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), [form1, null]);
+
+ pd1.setAttribute('form', 'form2');
+ assert_equals(pd1.form, form2);
+ assert_array_equals(pd1.formHistory(), [form1, null, form2]);
+
+ pd1.setAttribute('form', 'form3');
+ assert_equals(pd1.form, form3);
+ assert_array_equals(pd1.formHistory(), [form1, null, form2, form3]);
+
+ $('#container').innerHTML = '';
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), [form1, null, form2, form3, null]);
+}, 'Updating "form" content attribute');
+
+test(() => {
+ $('#container').innerHTML = '<form></form>' +
+ '<pre-defined id="pd1" form="target"></pre-defined>' +
+ '<form></form><form></form>';
+ const pd1 = $('#pd1');
+ const [form1, form2, form3] = document.forms;
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), []);
+
+ // The form1 is the only form element with id='target'.
+ form1.setAttribute('id', 'target');
+ assert_equals(pd1.form, form1);
+ assert_array_equals(pd1.formHistory(), [form1]);
+
+ // The first form element with id='target' is still form1.
+ form3.setAttribute('id', 'target');
+ assert_equals(pd1.form, form1);
+ assert_array_equals(pd1.formHistory(), [form1]);
+
+ // The form3 is the only form element with id='target'.
+ form1.removeAttribute('id');
+ assert_equals(pd1.form, form3);
+ assert_array_equals(pd1.formHistory(), [form1, form3]);
+
+ // The first form element with id='target' is form2 now.
+ form2.setAttribute('id', 'target');
+ assert_equals(pd1.form, form2);
+ assert_array_equals(pd1.formHistory(), [form1, form3, form2]);
+
+ // The form2 is the only form element with id='target'.
+ form3.removeAttribute('id');
+ assert_equals(pd1.form, form2);
+ assert_array_equals(pd1.formHistory(), [form1, form3, form2]);
+
+ // No form element has id='target'.
+ form2.removeAttribute('id');
+ assert_equals(pd1.form, null);
+ assert_array_equals(pd1.formHistory(), [form1, form3, form2, null]);
+}, 'Updating "id" attribute of form element');
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/form-disabled-callback.html b/testing/web-platform/tests/custom-elements/form-associated/form-disabled-callback.html
new file mode 100644
index 0000000000..954c3f3f6e
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/form-disabled-callback.html
@@ -0,0 +1,114 @@
+<!DOCTYPE html>
+<title>formDisabledCallback, and :disabled :enabled pseudo classes</title>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/html/semantics/forms/form-submission-0/resources/targetted-form.js"></script>
+<script>
+class MyControl extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.internals_ = this.attachInternals();
+ this.internals_.setFormValue('my-control-value');
+ this.disabledHistory_ = [];
+ }
+
+ formDisabledCallback(isDisabled) {
+ this.disabledHistory_.push(isDisabled);
+ }
+ disabledHistory() {
+ return this.disabledHistory_;
+ }
+}
+customElements.define('my-control', MyControl);
+
+test(() => {
+ const control = new MyControl();
+ assert_true(control.matches(':enabled'));
+ assert_false(control.matches(':disabled'));
+
+ control.setAttribute('disabled', '');
+ assert_false(control.matches(':enabled'));
+ assert_true(control.matches(':disabled'));
+
+ control.removeAttribute('disabled', '');
+ assert_true(control.matches(':enabled'));
+ assert_false(control.matches(':disabled'));
+
+ assert_array_equals(control.disabledHistory(), [true, false]);
+}, 'Adding/removing disabled content attribute');
+
+test(() => {
+ const container = document.createElement('fieldset');
+ container.innerHTML = '<fieldset><fieldset><my-control></my-control></fieldset></fieldset>';
+ const middleFieldset = container.firstChild;
+ const control = container.querySelector('my-control');
+
+ assert_true(control.matches(':enabled'));
+ assert_false(control.matches(':disabled'));
+
+ middleFieldset.disabled = true;
+ assert_false(control.matches(':enabled'));
+ assert_true(control.matches(':disabled'));
+
+ middleFieldset.disabled = false;
+ assert_true(control.matches(':enabled'));
+ assert_false(control.matches(':disabled'));
+
+ container.disabled = true;
+ assert_false(control.matches(':enabled'));
+ assert_true(control.matches(':disabled'));
+ control.remove();
+ assert_true(control.matches(':enabled'));
+ assert_false(control.matches(':disabled'));
+
+ middleFieldset.appendChild(control);
+ assert_false(control.matches(':enabled'));
+ assert_true(control.matches(':disabled'));
+
+ assert_array_equals(control.disabledHistory(), [true, false, true, false, true]);
+}, 'Relationship with FIELDSET');
+
+test(() => {
+ const form = document.createElement('form');
+ document.body.appendChild(form);
+ form.innerHTML = '<my-control name="n1" disabled></my-control><input name="n2">'
+ const formData = new FormData(form);
+ assert_equals(formData.get('n1'), null);
+}, 'A disabled form-associated custom element should not provide an entry for it');
+
+promise_test(async t => {
+ let form = populateForm('<my-control name=d disabled></my-control>' +
+ '<my-control name=e></my-control>');
+ let query = await submitPromise(form, form.previousSibling);
+ assert_equals(query.indexOf('d=my-control-value'), -1);
+ assert_not_equals(query.indexOf('e=my-control-value'), -1);
+}, 'A disabled form-associated custom element should not submit an entry for it');
+
+test(() => {
+ const control = new MyControl();
+ document.body.appendChild(control);
+ control.setAttribute('tabindex', '0');
+ control.setAttribute('disabled', '');
+ control.focus();
+ assert_not_equals(document.activeElement, control);
+
+ control.removeAttribute('disabled');
+ control.focus();
+ assert_equals(document.activeElement, control);
+}, 'Disabled attribute affects focus-capability');
+
+test(() => {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ // inneHTML upgrades my-control at its CEReacions timing.
+ container.innerHTML = '<my-control disabled>';
+ assert_array_equals(container.firstChild.disabledHistory(), [true]);
+
+ container.innerHTML = '<fieldset disabled><my-control>';
+ assert_array_equals(container.querySelector('my-control').disabledHistory(), [true]);
+}, 'Upgrading an element with disabled content attribute');
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/form-elements-namedItem.html b/testing/web-platform/tests/custom-elements/form-associated/form-elements-namedItem.html
new file mode 100644
index 0000000000..ab39422b5e
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/form-elements-namedItem.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<script>
+customElements.define('custom-input', class extends HTMLElement {
+ static get formAssociated() {return true;}
+});
+</script>
+
+<form id="custom-form">
+ <custom-input id="custom-1" name="alone"></custom-input>
+ <custom-input id="custom-2" name="group"></custom-input>
+ <custom-input id="custom-3" name="group"></custom-input>
+ <custom-button id="custom-4" name="upgrade"></custom-button>
+</form>
+<custom-input id="custom-5" name="group" form="custom-form"></custom-input>
+<custom-button id="custom-6" name="group" form="custom-form"></custom-button>
+
+<script>
+test(() => {
+ const formElements = document.forms[0].elements;
+ assert_equals(formElements['invalid'],undefined);
+ assert_equals(formElements['alone'],document.getElementById('custom-1'),'Single input should be returned as-is');
+ assert_true(formElements['group'] instanceof RadioNodeList,'Repeated names should result in RadioNodeList');
+ const expected = [document.getElementById('custom-2'),
+ document.getElementById('custom-3'),
+ document.getElementById('custom-5')];
+ assert_array_equals(formElements['group'],expected,'Repeated names should be contained in RadioNodeList, in tree order');
+}, 'Form associated custom elements should work with document.forms.elements.namedItem()');
+
+test(() => {
+ const formElements = document.forms[0].elements;
+ assert_equals(formElements['upgrade'],undefined);
+ customElements.define('custom-button', class extends HTMLElement {
+ static get formAssociated() {return true;}
+ });
+ assert_equals(formElements['upgrade'],document.getElementById('custom-4'),'Single button should be returned after upgrading');
+ const expected = [document.getElementById('custom-2'),
+ document.getElementById('custom-3'),
+ document.getElementById('custom-5'),
+ document.getElementById('custom-6')];
+ assert_array_equals(formElements['group'],expected,'Repeated names should be contained in RadioNodeList, in tree order after upgrading');
+}, 'Form associated custom elements should work with document.forms.elements.namedItem() after upgrading');
+
+test(() => {
+ const formElements = document.forms[0].elements;
+ assert_equals(formElements['alone'],document.getElementById('custom-1'),'Single input should be returned as-is');
+ const expected = [document.getElementById('custom-2'),
+ document.getElementById('custom-3'),
+ document.getElementById('custom-5'),
+ document.getElementById('custom-6')];
+ assert_array_equals(formElements['group'],expected,'Repeated names should be contained in RadioNodeList, in tree order after upgrading');
+ document.getElementById('custom-1').setAttribute("name", "group");
+ assert_equals(formElements['alone'],undefined);
+ const expectedNew = [document.getElementById('custom-1'),
+ document.getElementById('custom-2'),
+ document.getElementById('custom-3'),
+ document.getElementById('custom-5'),
+ document.getElementById('custom-6')];
+ assert_array_equals(formElements['group'],expectedNew,'Repeated names should be contained in RadioNodeList, in tree order after updating name attribute');
+}, 'Form associated custom elements should work with document.forms.elements.namedItem() after updating the name attribute');
+
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/form-reset-callback.html b/testing/web-platform/tests/custom-elements/form-associated/form-reset-callback.html
new file mode 100644
index 0000000000..8b8497f8b6
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/form-reset-callback.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<body>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+class MyControl extends HTMLElement {
+ static get formAssociated() { return true; }
+
+ constructor() {
+ super();
+ this.resetCalled_ = false;
+ }
+
+ formResetCallback() {
+ this.resetCalled_ = true;
+ }
+ get resetCalled() { return this.resetCalled_; }
+}
+customElements.define('my-control', MyControl);
+
+test(() => {
+ document.body.insertAdjacentHTML('beforeend',
+ '<form><my-control></my-control></form>');
+ let form = document.body.lastChild;
+ let custom = form.firstChild;
+ form.reset();
+ assert_true(custom.resetCalled);
+}, 'form.reset() should trigger formResetCallback');
+
+test(() => {
+ document.body.insertAdjacentHTML('beforeend',
+ '<form><my-control></my-control><output>default</output></form>');
+ let form = document.body.lastChild;
+ let custom = form.firstChild;
+ let output = form.lastChild;
+ output.value = 'updated';
+ output.addEventListener('DOMSubtreeModified', () => {
+ assert_false(custom.resetCalled, 'formResetCallback should not be ' +
+ 'called before built-in control\'s reset');
+ });
+ form.reset();
+ assert_true(custom.resetCalled);
+}, 'form.reset(): formResetCallback is called after reset of the last ' +
+ 'built-in form control and before the next statement.');
+
+promise_test(() => {
+ document.body.insertAdjacentHTML('beforeend',
+ '<form><my-control></my-control><input type=reset></form>');
+ let form = document.body.lastChild;
+ let custom = form.firstChild;
+ let resetButton = form.lastChild;
+ assert_false(custom.resetCalled);
+ resetButton.click();
+ assert_false(custom.resetCalled);
+ return Promise.resolve().then(() => assert_true(custom.resetCalled));
+}, 'Clicking a reset button invokes formResetCallback in a microtask');
+</script>
+</body>
diff --git a/testing/web-platform/tests/custom-elements/form-associated/label-delegatesFocus.html b/testing/web-platform/tests/custom-elements/form-associated/label-delegatesFocus.html
new file mode 100644
index 0000000000..67f4f61e49
--- /dev/null
+++ b/testing/web-platform/tests/custom-elements/form-associated/label-delegatesFocus.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<link rel=author href="mailto:jarhar@chromium.org">
+<link rel=help href="https://bugs.chromium.org/p/chromium/issues/detail?id=1300587">
+<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>
+
+<form>
+ <label for=custom>label</label>
+ <my-custom-element id=custom></my-custom-element>
+</form>
+
+<script>
+class MyCustomElement extends HTMLElement {
+ static formAssociated = true;
+ constructor() {
+ super();
+ const root = this.attachShadow({
+ delegatesFocus: true,
+ mode: 'open'
+ });
+ root.appendChild(document.createElement('input'));
+ }
+};
+customElements.define('my-custom-element', MyCustomElement);
+
+window.onload = () => {
+ promise_test(async () => {
+ const label = document.querySelector('label');
+ const customElement = document.querySelector('my-custom-element');
+ const input = customElement.shadowRoot.querySelector('input');
+ await test_driver.click(label);
+ assert_equals(document.activeElement, customElement);
+ assert_equals(customElement.shadowRoot.activeElement, input);
+ }, `Clicking on a label for a form associated custom element with delegatesFocus should focus the custom element's focus delegate.`);
+};
+</script>