commit 5831bb69857a0be70d94c8d8ebc799215139f350
parent b940f221750f936aebd3b856ccc10c2aba39bc94
Author: Stephanie Y Zhang <stephanie.zhang@microsoft.com>
Date: Fri, 7 Nov 2025 09:02:31 +0000
Bug 1998603 [wpt PR 55901] - [FormControlRange] Add getClientRects/getBoundingClientRect, a=testonly
Automatic update from web-platform-tests
[FormControlRange] Add getClientRects/getBoundingClientRect
Add geometry methods for FormControlRange that mirror DOM Range behavior
but applied to value space/text for <input>/<textarea>.
Behavior:
- getClientRects(): returns rects for the selected value; collapsed,
disconnected, or display:none returns an empty list.
- getBoundingClientRect(): returns the union of the client rects.
For a caret/collapsed range, it returns a zero-width box with the line
height.
The implementation forces style and layout update, locates the single
inner editor text node, clamps offsets to the current value, and builds
a temporary DOM Range over that node to obtain exact geometry. This
preserves bidirectional ordering, soft wrapping, and other range
dependent layout effects.
Explainer: github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/FormControlRange/explainer.md
Low-Coverage-Reason: COVERAGE_UNDERREPORTED
Bug: 421421332
Change-Id: I2fb217f56f1633f4b174c760c8c78e2190a47047
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7013433
Reviewed-by: Mason Freed <masonf@chromium.org>
Commit-Queue: Ana Sollano Kim <ansollan@microsoft.com>
Reviewed-by: Ana Sollano Kim <ansollan@microsoft.com>
Cr-Commit-Position: refs/heads/main@{#1540993}
--
wpt-commits: 34929bf8385e51ee43917608c9661d1260bd0e1e
wpt-pr: 55901
Diffstat:
3 files changed, 406 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-basic.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-basic.html
@@ -0,0 +1,130 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body></body>
+<script>
+'use strict';
+const controls = ['input','textarea'];
+
+function setupControl(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text" id="test">'
+ : '<textarea id="test"></textarea>';
+ const element = document.getElementById('test');
+ element.value = value;
+ element.focus();
+ // Stabilize layout.
+ element.style.fontFamily = 'monospace';
+ element.style.fontSize = '16px';
+ element.style.lineHeight = '20px';
+ element.style.padding = '0';
+ element.style.border = '0';
+ element.style.margin = '8px';
+ element.style.boxSizing = 'content-box';
+
+ // Zero scroll offsets so client rects are relative to a known origin.
+ if ('scrollTop' in element) element.scrollTop = 0;
+ if ('scrollLeft' in element) element.scrollLeft = 0;
+ return element;
+}
+function setupFormControlRange(element, startOffset, endOffset){
+ const range = new FormControlRange();
+ range.setFormControlRange(element, startOffset, endOffset);
+ return range;
+}
+
+function assert_rect_inside(inner, outer, msg = '') {
+ const rounding = 0.5; // sub-pixel fuzz
+ assert_greater_than_equal(inner.left + rounding, outer.left, msg + 'left inside');
+ assert_less_than_equal(inner.right - rounding, outer.right, msg + 'right inside');
+}
+
+controls.forEach(controlType => {
+ test(() => {
+ // Collapsed range (caret): no client rects; bounding rect is a caret box
+ // (zero width, non-zero height) positioned within the control.
+ const element = setupControl(controlType, 'Hello');
+ const range = setupFormControlRange(element, 2, 2);
+ const caretRect = range.getBoundingClientRect();
+ assert_equals(range.getClientRects().length, 0, 'collapsed: no client rects');
+ assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0');
+ assert_greater_than(caretRect.height, 0, 'caret height greater than 0');
+ assert_rect_inside(caretRect, element.getBoundingClientRect(), 'caret inside ');
+ }, `Collapsed caret geometry (${controlType})`);
+
+ test(() => {
+ // Non-collapsed selection: non-zero geometry, all rects contained in control.
+ const element = setupControl(controlType, 'ABCDE');
+ const range = setupFormControlRange(element, 1, 4);
+ const boundingRect = range.getBoundingClientRect();
+ const clientRects = Array.from(range.getClientRects());
+ assert_greater_than(boundingRect.width, 0, 'selection width greater than 0');
+ assert_greater_than(boundingRect.height, 0, 'selection height greater than 0');
+ assert_rect_inside(boundingRect, element.getBoundingClientRect());
+ assert_greater_than_equal(clientRects.length, 1);
+ clientRects.forEach((clientRect, index) =>
+ assert_rect_inside(clientRect, element.getBoundingClientRect(), 'rect[' + index + '] ')
+ );
+ }, `Simple selection geometry (${controlType})`);
+
+ test(() => {
+ // If the control is removed from the DOM, geometry should be empty.
+ const element = setupControl(controlType, 'ABCDE');
+ const range = setupFormControlRange(element, 0, element.value.length);
+ assert_greater_than(range.getBoundingClientRect().width,0,'pre removal width greater than 0');
+ element.remove();
+ const boundingRect = range.getBoundingClientRect();
+ assert_approx_equals(boundingRect.width, 0, 0.05, 'width should be 0 after removal');
+ assert_equals(range.getClientRects().length, 0);
+ }, `Geometry empty after control removal (${controlType})`);
+
+ test(() => {
+ const value = 'ABCDE';
+ const element = setupControl(controlType, value);
+ [0, Math.floor(value.length / 2), value.length].forEach(position => {
+ const range = setupFormControlRange(element, position, position);
+ const caretRect = range.getBoundingClientRect();
+ assert_equals(range.getClientRects().length, 0, 'collapsed caret has no rects');
+ assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0');
+ assert_greater_than(caretRect.height, 0, 'caret height greater than 0');
+ });
+ }, `Collapsed caret at start/middle/end (${controlType})`);
+
+ test(() => {
+ // Full selection should yield a non-zero bounding box inside the control.
+ // (For textarea, the string includes a hard newline to exercise multi-line.)
+ const value = controlType === 'textarea' ? 'First line\nSecond line' : 'ABCDE';
+ const element = setupControl(controlType, value);
+ const range = setupFormControlRange(element, 0, element.value.length);
+ const boundingRect = range.getBoundingClientRect();
+ assert_greater_than(boundingRect.width, 0, 'full width greater than 0');
+ assert_greater_than(boundingRect.height, 0, 'full height greater than 0');
+ assert_rect_inside(boundingRect, element.getBoundingClientRect(), 'full selection inside');
+ }, `Full selection bounding box inside element (${controlType})`);
+
+ test(() => {
+ // Backwards offsets are auto-collapsed by setFormControlRange; caret geometry applies.
+ document.body.innerHTML = controlType === 'input' ? '<input type="text" value="Test">'
+ : '<textarea>Test</textarea>';
+ const element = document.body.firstElementChild; const range = new FormControlRange();
+ range.setFormControlRange(element, 3, 1);
+ assert_true(range.collapsed, 'collapsed');
+ const caretRect = range.getBoundingClientRect();
+ assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0');
+ assert_greater_than(caretRect.height, 0, 'caret height greater than 0');
+ }, `Backwards offsets collapse (${controlType})`);
+
+ test(() => {
+ // Elements with display:none have no rendered geometry; ranges report empty rects.
+ document.body.innerHTML = controlType === 'input'
+ ? '<input type="text" id="displayNone" style="display:none" value="hidden">'
+ : '<textarea id="displayNone" style="display:none">hidden</textarea>';
+ const element = document.getElementById('displayNone');
+ const range = setupFormControlRange(element, 0, element.value.length);
+ const boundingRect = range.getBoundingClientRect();
+ assert_approx_equals(boundingRect.width, 0, 0.05, 'width should be 0 (display:none)');
+ assert_approx_equals(boundingRect.height, 0, 0.05, 'height should be 0 (display:none)');
+ assert_equals(range.getClientRects().length, 0);
+ }, `display:none empty geometry (${controlType})`);
+});
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-complexity-and-visibility.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-complexity-and-visibility.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body></body>
+<script>
+'use strict';
+const controls = ['input','textarea'];
+
+function setupControl(control, value) {
+ document.body.innerHTML = control === 'input'
+ ? '<input type="text" id="test">'
+ : '<textarea id="test"></textarea>';
+ const element = document.getElementById('test');
+ element.value = value;
+ element.focus();
+ // Stabilize layout.
+ element.style.fontFamily = 'monospace';
+ element.style.fontSize = '16px';
+ element.style.lineHeight = '20px';
+ element.style.padding = '0';
+ element.style.border = '0';
+ element.style.margin = '8px';
+ element.style.boxSizing = 'content-box';
+
+ // Zero scroll offsets so client rects are relative to a known origin.
+ if ('scrollTop' in element) element.scrollTop = 0;
+ if ('scrollLeft' in element) element.scrollLeft = 0;
+ return element;
+}
+function assert_rect_inside(inner, outer, msg = '') {
+ const rounding = 0.5; // sub-pixel fuzz.
+ assert_greater_than_equal(inner.left + rounding, outer.left, msg + 'left inside');
+ assert_less_than_equal(inner.right - rounding, outer.right, msg + 'right inside');
+}
+
+function rect(range, element, label='') {
+ const r = range.getBoundingClientRect();
+ assert_rect_inside(r, element.getBoundingClientRect(), label);
+ return r;
+}
+function setupFormControlRange(element, startOffset, endOffset) {
+ const range = new FormControlRange();
+ range.setFormControlRange(element, startOffset, endOffset);
+ return range;
+}
+
+controls.forEach(controlType => {
+ // Bidirectional text: a full selection spanning LTR and RTL text
+ // should produce a non-zero bounding box within the control.
+ test(() => {
+ const mixed = 'abc \u0645\u0631\u062D\u0628\u0627 def'; // abc مرحبا def.
+ const element = setupControl(controlType, mixed);
+ const range = setupFormControlRange(element, 0, mixed.length);
+ const boundingRect = rect(range, element, 'Bidirectional text full selection inside: ');
+ assert_greater_than(boundingRect.width, 0);
+ assert_greater_than(boundingRect.height, 0);
+ }, `Bidirectional mixed text full selection geometry (${controlType})`);
+
+ test(() => {
+ // Surrogate pair handling: geometry should not split a single emoji.
+ // Check that a full pair has width, and a single code unit slice
+ // does not produce a larger width than the full pair.
+ const value = '\ud83c\udf20A\ud83c\udf20'; // 🌠A🌠 in UTF-16.
+ const element = setupControl(controlType, value);
+
+ const fullRects = Array.from(setupFormControlRange(element, 0, value.length).getClientRects());
+ assert_greater_than_equal(fullRects.length, 1, 'full selection has rects');
+
+ const fullWidth = fullRects[0].width;
+ const wHalf1 = Array.from(setupFormControlRange(element, 0, 1).getClientRects())[0]?.width ?? 0;
+ const wHalf2 = Array.from(setupFormControlRange(element, 1, 2).getClientRects())[0]?.width ?? 0;
+ const wPair = Array.from(setupFormControlRange(element, 0, 2).getClientRects())[0]?.width ?? 0;
+
+ assert_greater_than(wPair, 0, 'pair width greater than 0');
+ assert_greater_than_equal(wPair + 0.05, Math.max(wHalf1, wHalf2), 'full pair width greater than or equal to any single code-unit slice');
+ assert_greater_than(fullWidth, 0, 'full width greater than 0');
+ }, `Surrogate pair width normalization invariants (${controlType})`);
+
+ test(() => {
+ // visibility:hidden controls still produce a caret box with zero width.
+ document.body.innerHTML = controlType === 'input'
+ ? '<input id="test" style="visibility:hidden" value="abc">'
+ : '<textarea id="test" style="visibility:hidden">abc</textarea>';
+ const hidden = document.getElementById('test');
+ hidden.focus();
+ const range = setupFormControlRange(hidden, 2, 2);
+ const caretRect = rect(range, hidden, 'hidden caret inside: ');
+ assert_greater_than(caretRect.height, 0, 'caret height greater than 0');
+ assert_approx_equals(caretRect.width, 0, 0.05, 'caret width should be 0');
+ }, `visibility:hidden caret geometry (${controlType})`);
+});
+</script>
+\ No newline at end of file
diff --git a/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-multiline-and-mutations.html b/testing/web-platform/tests/dom/ranges/tentative/FormControlRange-geometry-multiline-and-mutations.html
@@ -0,0 +1,180 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body></body>
+<script>
+'use strict';
+const controls = ['input','textarea'];
+
+function setupControl(control, value) {
+ document.body.innerHTML = control === 'input' ? '<input type="text" id="test">'
+ : '<textarea id="test"></textarea>';
+ const element = document.getElementById('test');
+ element.value = value;
+ element.focus();
+ element.setSelectionRange(0, 0);
+
+ // Stabilize layout for cross-platform consistency.
+ element.style.fontFamily = 'monospace';
+ element.style.fontSize = '16px';
+ element.style.lineHeight = '20px';
+ element.style.padding = '0';
+ element.style.border = '0';
+ element.style.margin = '8px';
+ element.style.boxSizing = 'content-box';
+ // Zero scroll offsets so client rects are relative to a known origin.
+ if ('scrollLeft' in element) element.scrollLeft = 0;
+ return element;
+}
+function setupFormControlRange(element, startOffset, endOffset){
+ const range = new FormControlRange();
+ range.setFormControlRange(element, startOffset, endOffset);
+ return range;
+}
+
+function assert_rect_inside(inner, outer, msg = '') {
+ const left_overflow = outer.left > inner.left ? outer.left - inner.left : 0;
+ const right_overflow = inner.right > outer.right ? inner.right - outer.right : 0;
+ assert_approx_equals(left_overflow, 0, 0.5, msg + 'left inside');
+ assert_approx_equals(right_overflow, 0, 0.5, msg + 'right inside');
+}
+
+function rect(range, element, label='') {
+ const r = range.getBoundingClientRect();
+ assert_rect_inside(r, element.getBoundingClientRect(), label);
+ return r;
+}
+
+test(() => {
+ // Partial selection spanning a hard newline produces non-zero geometry and at least one client rect.
+ const textareaElement = setupControl('textarea', 'first line\nSECOND LINE\nthird');
+ const firstLine = 'first line';
+ const range = setupFormControlRange(textareaElement, firstLine.length - 4, firstLine.length + 5);
+ const boundingRect = rect(range, textareaElement, 'Bounding box inside: ');
+ assert_greater_than(boundingRect.width, 0);
+ assert_greater_than(boundingRect.height, 0);
+ assert_greater_than_equal(range.getClientRects().length, 1);
+}, 'Partial hard-newline selection (textarea)');
+
+test(() => {
+ // Selection that spans a blank line should still produce non-zero geometry.
+ const textareaElement = setupControl('textarea', 'line1\n\nline3');
+ const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length);
+ const boundingRect = rect(range, textareaElement, 'Bounding box inside: ');
+ assert_greater_than(boundingRect.height, 0);
+ assert_greater_than(boundingRect.width, 0);
+}, 'Selection spanning blank line (textarea)');
+
+test(() => {
+ // Soft-wrapped long logical line yields multiple client rects due to wrapping.
+ const textareaElement = setupControl('textarea', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789 abcdefghijklmnopqrstuvwxyz');
+ textareaElement.style.whiteSpace = 'pre-wrap';
+ textareaElement.style.width = '160px';
+ const range = setupFormControlRange(textareaElement, 0, textareaElement.value.length);
+ const boundingRect = rect(range, textareaElement, 'Bounding box inside: ');
+ assert_greater_than_equal(range.getClientRects().length, 2, 'soft wrap multiple rects');
+}, 'Soft-wrapped long single line (textarea)');
+
+test(() => {
+ // Caret before newline and start of next line have non-decreasing y and expected x ordering.
+ const value = '123456\n789012';
+ const textareaElement = setupControl('textarea', value);
+ const caretBeforeNewlineRect = rect(setupFormControlRange(textareaElement, 6, 6), textareaElement, 'caret before newline inside: ');
+ const caretNextLineRect = rect(setupFormControlRange(textareaElement, 7, 7), textareaElement, 'caret next line inside: ');
+ // Allow caret before newline to be aligned or slightly (sub-pixel) to the right.
+ assert_greater_than_equal(caretBeforeNewlineRect.x + 0.5, caretNextLineRect.x, 'caret before newline at or to the right (epsilon) of next line start');
+ assert_less_than_equal(caretBeforeNewlineRect.y, caretNextLineRect.y, 'next line lower or equal y');
+}, 'Collapsed caret across newline parity (textarea)');
+
+test(() => {
+ // Live insert/delete operations adjust selection left/width as expected (grow/shift/shrink).
+ const inputElement = setupControl('input', 'ABCDE');
+ const range = setupFormControlRange(inputElement, 1, 3);
+
+ const leftBeforeInsertion = rect(range, inputElement).left;
+ inputElement.setRangeText('ZZ', 0, 0);
+ assert_greater_than(rect(range, inputElement).left, leftBeforeInsertion, 'insert before shifts right');
+
+ const widthBeforeInteriorInsertion = rect(range, inputElement).width;
+ inputElement.setRangeText('QQ', range.startOffset, range.startOffset);
+ assert_greater_than(rect(range, inputElement).width, widthBeforeInteriorInsertion, 'insert inside expands width');
+
+ const leftBeforeAppend = rect(range, inputElement).left;
+ inputElement.setRangeText('TT', inputElement.value.length, inputElement.value.length);
+ assert_approx_equals(rect(range, inputElement).left, leftBeforeAppend, 0.5, 'append does not shift left edge');
+
+ const leftBeforeDeletion = rect(range, inputElement).left;
+ inputElement.setRangeText('', 0, 2);
+ assert_less_than(rect(range, inputElement).left, leftBeforeDeletion, 'delete before shifts left');
+
+ const widthBeforeOverlapDeletion = rect(range, inputElement).width;
+ inputElement.setRangeText('', range.startOffset - 1, range.startOffset + 1);
+ assert_less_than(rect(range, inputElement).width, widthBeforeOverlapDeletion, 'overlap delete shrinks width');
+}, 'Input live insertion/deletion adjustments');
+
+test(() => {
+ // Interior deletion shrinks width; insertion before selection shifts selection right in textarea.
+ const textareaElement = setupControl('textarea', 'ABCDE');
+ const range = setupFormControlRange(textareaElement, 1, 4);
+ const widthBeforeDeletion = rect(range, textareaElement).width;
+ textareaElement.setRangeText('', 2, 3);
+ assert_less_than(rect(range, textareaElement).width, widthBeforeDeletion, 'textarea interior deletion shrinks width');
+ const leftBeforeInsertion = rect(range, textareaElement).left;
+ textareaElement.setRangeText('ZZ', 0, 0);
+ assert_greater_than(rect(range, textareaElement).left, leftBeforeInsertion, 'textarea insertion before shifts right');
+}, 'Textarea interior deletion & insertion adjustments');
+
+controls.forEach(controlType => {
+ test(() => {
+ // Inserting text inside selection increases its width.
+ const element = setupControl(controlType, 'ABCDE');
+ const range = setupFormControlRange(element, 1, 4);
+ const widthBeforeInsertion = rect(range, element).width;
+ element.setRangeText('ZZ', 2, 2);
+ assert_greater_than(rect(range, element).width, widthBeforeInsertion, 'insertion inside expands width');
+ }, `Insertion inside expands width (${controlType})`);
+
+ test(() => {
+ // Deleting full selection collapses to a caret.
+ const element = setupControl(controlType, 'ABCDE');
+ const range = setupFormControlRange(element, 1, 4);
+ assert_greater_than(rect(range, element).width, 0, 'pre width greater than 0');
+ element.setRangeText('', 1, 4);
+ assert_true(range.collapsed, 'collapsed after deletion');
+ assert_equals(range.getClientRects().length, 0, 'no client rects');
+ assert_approx_equals(rect(range, element).width, 0, 0.05, 'caret width should be 0');
+ }, `Deletion collapses geometry (${controlType})`);
+
+ test(() => {
+ // Shrink near end clamps range end; subsequent insertion at end does not auto-extend.
+ const element = setupControl(controlType, 'HelloWorld');
+ const range = setupFormControlRange(element, 0, element.value.length);
+ element.setRangeText('', 7, 10);
+ const endAfterShrink = range.endOffset;
+ assert_equals(endAfterShrink, element.value.length, 'end clamped after shrink');
+ element.setRangeText('XYZ', 7, 7);
+ assert_equals(range.endOffset, endAfterShrink, 'end remains stable after grow at end (does not auto-extend)');
+ }, `Shrink/grow end offset clamping (${controlType})`);
+
+ test(() => {
+ // Tail deletion clamps end offset while remaining selection geometry stays non-zero.
+ const element = setupControl(controlType, 'HelloWorld');
+ const range = setupFormControlRange(element, 0, element.value.length);
+ element.setRangeText('', 5, 10);
+ assert_equals(element.value, 'Hello', 'value shrunk to Hello');
+ assert_equals(range.endOffset, element.value.length, 'end offset clamped to new value length');
+ const rectBox = rect(range, element);
+ assert_greater_than(rectBox.width, 0, 'geometry still non-zero after tail deletion');
+ }, `Tail deletion clamps end offset (${controlType})`);
+});
+
+test(() => {
+ // Deleting selected text collapses to a caret with zero width.
+ const inputElement = setupControl('input', 'ABCDE');
+ const range = setupFormControlRange(inputElement, 1, 4);
+ inputElement.setRangeText('', 1, 4);
+ assert_true(range.collapsed, 'collapsed after deletion');
+ assert_approx_equals(rect(range, inputElement).width, 0, 0.05, 'width should be 0');
+}, 'Deletion collapses selection (input explicit)');
+</script>
+\ No newline at end of file