commit a5b5babda50e461ab56b85a8b4e71319690aa84b
parent 8393618b27242c3507a36849750d11e917bc2e70
Author: Stephanie Y Zhang <stephanie.zhang@microsoft.com>
Date: Tue, 21 Oct 2025 10:36:11 +0000
Bug 1995109 [wpt PR 55522] - [FormControlRange] Live updates for user typing, a=testonly
Automatic update from web-platform-tests
[FormControlRange] Live updates for user typing
Implement live offset updates for FormControlRange during user-initiated
edits (typing, backspace/delete, selection replacement). Like DOM Range,
updates are applied after the observable value mutation and before
'input' event listeners run. This ensures ranges reflect the new text
state within the same task in which the edit is observed.
Ranges shift, contract, or collapse to preserve coverage of the original
logical substring. A cancelled beforeinput leaves ranges unchanged. A
pre-edit snapshot taken at beforeinput is committed on the first
observable value change as a single replace span for stability.
Follow-up CL(s) for live updates will extend support to programmatic
edits and richer scenarios (e.g. drag/drop, IME).
Explainer: github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/FormControlRange/explainer.md
Low-Coverage-Reason: COVERAGE_UNDERREPORTED
Bug: 421421332
Change-Id: Ic59fced46837275408fb90d601506306a3ef6771
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7001481
Commit-Queue: Stephanie Zhang <stephanie.zhang@microsoft.com>
Reviewed-by: Ana Sollano Kim <ansollan@microsoft.com>
Reviewed-by: Mason Freed <masonf@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1531733}
--
wpt-commits: f943664afb13ad4efc29c78a77a9fd9d53ae7388
wpt-pr: 55522
Diffstat:
3 files changed, 312 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-interactive-basic.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-interactive-basic.html
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<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>
+<script src="/resources/testdriver-actions.js"></script>
+<body></body>
+<script>
+'use strict';
+
+const BACKSPACE = '\uE003';
+const DELETE = '\uE017';
+const controls = ['input', 'textarea'];
+
+function setup(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text">' : '<textarea></textarea>';
+ const element = document.body.firstElementChild;
+ element.value = value;
+ element.focus();
+ return element;
+}
+
+async function typeKeys(element, text) { await test_driver.send_keys(element, text); }
+
+function makeRange(element, start, end) {
+ const range = new FormControlRange();
+ range.setFormControlRange(element, start, end);
+ return range;
+}
+
+controls.forEach(control => {
+ [
+ {
+ name: 'at start boundary keeps start; end extends (multi-char)',
+ init: { value:'ABCDE', s:1, e:3, caret:1, ins:'pq' },
+ exp: { s:1, e:5, text:'pqBC' }
+ },
+ {
+ name: 'inside range extends end by length',
+ init: { value:'ABCDE', s:1, e:4, caret:2, ins:'pq' },
+ exp: { s:1, e:6, text:'BpqCD' }
+ },
+ {
+ name: 'at end boundary leaves offsets unchanged (multi-char)',
+ init: { value:'ABCDE', s:1, e:3, caret:3, ins:'pq' },
+ exp: { s:1, e:3, text:'BC' }
+ },
+ ].forEach(({ name, init, exp }) => {
+ promise_test(async t => {
+ const element = setup(control, init.value);
+ const range = makeRange(element, init.s, init.e);
+ element.setSelectionRange(init.caret, init.caret);
+ await typeKeys(element, init.ins);
+ assert_equals(range.startOffset, exp.s, 'startOffset');
+ assert_equals(range.endOffset, exp.e, 'endOffset');
+ assert_equals(range.toString(), exp.text, 'range text');
+ }, `Insertion: ${name} (${control}).`);
+ });
+
+ [
+ { name: 'single char inside range',
+ init:{ value:'ABCDE', s:1, e:4, a:2, b:3, text:'q' },
+ exp:{ s:1, e:4, text:'BqD' }
+ },
+ ].forEach(({ name, init, exp }) => {
+ promise_test(async t => {
+ const element = setup(control, init.value);
+ const range = makeRange(element, init.s, init.e);
+ element.setSelectionRange(init.a, init.b);
+ await typeKeys(element, init.text);
+ assert_equals(range.startOffset, exp.s);
+ assert_equals(range.endOffset, exp.e);
+ assert_equals(range.toString(), exp.text);
+ }, `Equal-length replacement: ${name} (${control}).`);
+ });
+
+ [
+ { name:'backspace before range shifts both left',
+ init:{ value:'ABCDE', s:2, e:5, caret:2, key:BACKSPACE },
+ exp:{ value:'ACDE', s:1, e:4, text:'CDE' } },
+ { name:'forward delete inside range contracts end by 1',
+ init:{ value:'ABCDE', s:1, e:4, caret:2, key:DELETE },
+ exp:{ value:'ABDE', s:1, e:3, text:'BD' } },
+ { name:'forward delete at end boundary no effect on range',
+ init:{ value:'ABCDE', s:1, e:3, caret:3, key:DELETE },
+ exp:{ value:'ABCE', s:1, e:3, text:'BC' } },
+ { name:'backspace at control start is no-op',
+ init:{ value:'ABCDE', s:0, e:2, caret:0, key:BACKSPACE },
+ exp:{ value:'ABCDE', s:0, e:2, text:'AB' } },
+ ].forEach(({ name, init, exp }) => {
+ promise_test(async t => {
+ const element = setup(control, init.value);
+ const range = makeRange(element, init.s, init.e);
+ element.setSelectionRange(init.caret, init.caret);
+ await typeKeys(element, init.key);
+ assert_equals(element.value, exp.value);
+ assert_equals(range.startOffset, exp.s);
+ assert_equals(range.endOffset, exp.e);
+ assert_equals(range.toString(), exp.text);
+ }, `Deletion: ${name} (${control}).`);
+ });
+});
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-interactive-overlap-and-selection.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-interactive-overlap-and-selection.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<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>
+<script src="/resources/testdriver-actions.js"></script>
+<body></body>
+<script>
+'use strict';
+
+const KEY_BACKSPACE = '\uE003';
+const KEY_DELETE = '\uE017';
+const controls = ['input', 'textarea'];
+
+function setup(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text">' : '<textarea></textarea>';
+ const element = document.body.firstElementChild;
+ element.value = value;
+ element.focus();
+ return element;
+}
+
+async function typeKeys(element, text) { await test_driver.send_keys(element, text); }
+
+function makeRange(element, start, end) {
+ const range = new FormControlRange();
+ range.setFormControlRange(element, start, end);
+ return range;
+}
+
+controls.forEach(control => {
+ [
+ // Selection partially overlaps the start of the live range: deletion shrinks and shifts start.
+ { name:'partial overlap at start (delete selection)',
+ init:{ value:'ABCDE', rangeStart:2, rangeEnd:4, selA:1, selB:3, key:KEY_BACKSPACE },
+ expect:{ value:'ADE', start:1, end:2, text:'D' } },
+ // Selection partially overlaps the end of the live range: deletion contracts end.
+ { name:'partial overlap at end (delete selection)',
+ init:{ value:'ABCDE', rangeStart:1, rangeEnd:3, selA:2, selB:4, key:KEY_BACKSPACE },
+ expect:{ value:'ABE', start:1, end:2, text:'B' } },
+ ].forEach(tc => {
+ promise_test(async t => {
+ const element = setup(control, tc.init.value);
+ const range = makeRange(element, tc.init.rangeStart, tc.init.rangeEnd);
+ element.setSelectionRange(tc.init.selA, tc.init.selB);
+ if (tc.init.key) {
+ await typeKeys(element, tc.init.key);
+ } else if (tc.init.text) {
+ await typeKeys(element, tc.init.text);
+ }
+ assert_equals(element.value, tc.expect.value, 'post-edit element.value');
+ assert_equals(range.startOffset, tc.expect.start, 'range start');
+ assert_equals(range.endOffset, tc.expect.end, 'range end');
+ assert_equals(range.toString(), tc.expect.text, 'range text');
+ }, `Overlap: ${tc.name} (${control}).`);
+ });
+
+ [
+ { name:'delete selection exactly equal to range (collapse)',
+ init:{ value:'ABCDE', rangeStart:1, rangeEnd:3, selA:1, selB:3, key:KEY_BACKSPACE },
+ expect:{ value:'ADE', start:1, end:1, text:'' } },
+ { name:'delete subset inside range (contract end)',
+ init:{ value:'ABCDE', rangeStart:1, rangeEnd:4, selA:2, selB:3, key:KEY_BACKSPACE },
+ expect:{ value:'ABDE', start:1, end:3, text:'BD' } },
+ ].forEach(tc => {
+ promise_test(async t => {
+ const element = setup(control, tc.init.value);
+ const range = makeRange(element, tc.init.rangeStart, tc.init.rangeEnd);
+ element.setSelectionRange(tc.init.selA, tc.init.selB);
+ await typeKeys(element, tc.init.key);
+ assert_equals(element.value, tc.expect.value, 'post-delete value');
+ assert_equals(range.startOffset, tc.expect.start, 'start after delete');
+ assert_equals(range.endOffset, tc.expect.end, 'end after delete');
+ assert_equals(range.toString(), tc.expect.text, 'text after delete');
+ if (tc.expect.start === tc.expect.end) {
+ assert_true(range.collapsed, 'range collapsed');
+ }
+ }, `Selection deletion: ${tc.name} (${control}).`);
+ });
+
+ promise_test(async t => {
+ const element = setup(control, 'ABCDE');
+ const range = makeRange(element, 2, 4);
+ element.setSelectionRange(1, 1);
+ await typeKeys(element, KEY_DELETE);
+ assert_equals(element.value, 'ACDE');
+ assert_equals(range.startOffset, 1);
+ assert_equals(range.endOffset, 3);
+ assert_equals(range.toString(), 'CD');
+ }, `Boundary forward delete shifts range left (${control}).`);
+
+});
+</script>
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-update-event-order.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-update-event-order.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<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>
+<script src="/resources/testdriver-actions.js"></script>
+<body></body>
+<script>
+'use strict';
+
+// Verifies that FormControlRange updates are applied after the value mutation
+// but before 'input' event listeners execute. Specifically, offsets remain at
+// their pre-edit positions in 'beforeinput' and reflect the new positions in 'input'.
+
+const controls = ['input', 'textarea'];
+
+function setup(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text">' : '<textarea></textarea>';
+ const element = document.body.firstElementChild;
+ element.value = value;
+ element.focus();
+ return element;
+}
+
+async function typeKeys(element, text) { await test_driver.send_keys(element, text); }
+
+function makeRange(element, start, end) {
+ const range = new FormControlRange();
+ range.setFormControlRange(element, start, end);
+ return range;
+}
+
+controls.forEach(control => {
+ promise_test(async t => {
+ const element = setup(control, 'ABCDE');
+ const range = makeRange(element, 1, 3);
+
+ // Place caret before the range, then insert text.
+ element.setSelectionRange(0, 0);
+
+ const seen = { beforeinput: null, input: null };
+
+ element.addEventListener('beforeinput', t.step_func(e => {
+ seen.beforeinput = {
+ value: element.value,
+ start: range.startOffset,
+ end: range.endOffset,
+ rangeText: range.toString()
+ };
+ assert_equals(seen.beforeinput.start, 1, 'beforeinput start should be old');
+ assert_equals(seen.beforeinput.end, 3, 'beforeinput end should be old');
+ }), { once: true });
+
+ const inputPromise = new Promise(resolve => {
+ element.addEventListener('input', t.step_func(() => {
+ seen.input = {
+ value: element.value,
+ start: range.startOffset,
+ end: range.endOffset,
+ rangeText: range.toString()
+ };
+ resolve();
+ }), { once: true });
+ });
+
+ await typeKeys(element, 'Z');
+ await inputPromise;
+
+ // Range should have shifted forward by 1.
+ assert_equals(seen.input.value, 'ZABCDE', 'value after insertion');
+ assert_equals(seen.input.start, 2, 'updated start after insertion');
+ assert_equals(seen.input.end, 4, 'updated end after insertion');
+ assert_not_equals(seen.beforeinput, null, 'captured beforeinput');
+ assert_not_equals(seen.input, null, 'captured input');
+ assert_not_equals(seen.beforeinput.start, seen.input.start, 'start changed between events');
+ assert_not_equals(seen.beforeinput.end, seen.input.end, 'end changed between events');
+ }, `Event order: FormControlRange updates between beforeinput and input (${control}).`);
+
+ promise_test(async t => {
+ const element = setup(control, 'ABCDE');
+ const range = makeRange(element, 1, 3);
+ element.setSelectionRange(0, 0);
+
+ let beforeSnapshot = null;
+ const before = new Promise(resolve => {
+ element.addEventListener('beforeinput', t.step_func(e => {
+ e.preventDefault();
+ beforeSnapshot = {
+ value: element.value,
+ start: range.startOffset,
+ end: range.endOffset,
+ };
+ resolve();
+ }), { once: true });
+ });
+
+ let sawInput = false;
+ element.addEventListener('input', t.step_func(() => { sawInput = true; }));
+
+ // Attempt insertion, which will be canceled.
+ await typeKeys(element, 'Z');
+ await before;
+
+ // Ensure beforeinput fired and the edit was canceled.
+ assert_not_equals(beforeSnapshot, null, 'beforeinput captured');
+ assert_false(sawInput, 'input should not fire when beforeinput is canceled');
+ assert_equals(element.value, 'ABCDE', 'value unchanged after canceled beforeinput');
+ assert_equals(range.startOffset, 1, 'range start unchanged after cancel');
+ assert_equals(range.endOffset, 3, 'range end unchanged after cancel');
+ }, `Canceled beforeinput leaves FormControlRange unchanged (${control}).`);
+});
+</script>