Skip to content

Latest commit

 

History

History
632 lines (495 loc) · 30.3 KB

File metadata and controls

632 lines (495 loc) · 30.3 KB

FormControlRange

Authors:

Participate

Introduction

The current Range interface methods do not support retrieving or creating ranges that represent the value (rather than the element itself) of <textarea> and <input> elements. As a result, if web developers want to use the getBoundingClientRect() method in a <textarea> or <input> element to position a popup beneath the user's current caret for delivering contextual autocomplete suggestions or marking syntax errors as users type using the Custom Highlight API, they must find workarounds. These workarounds often involve cloning these elements and their styles into <div>s, which is both difficult to maintain and may impact the web application's performance.

This proposal aims to address these issues by introducing FormControlRange, a new AbstractRange subclass that serves as a way to reference spans of text within form control elements while preserving encapsulation.

User-Facing Problem

  1. Place a popup near the caret when the user presses a certain key, as shown in the following GIF.

popup-inside-div-example

  1. Mark syntax errors using the Custom Highlight API as shown in the following image.

highlight-api-example

Today, web developers have two options to implement these use cases:

Option 1: Cloning the form control element and copying styles into a <div>

If web authors already use a <textarea> and/or an <input> element in their applications, they might:

  1. Clone the elements as <div>s,
  2. Copy layout-affecting styles
  3. Use <div> to get a Range and:
    • For use case 1: call getBoundingClientRect().
    • For use case 2: create a Highlight object and use CSS.highlights.set('syntax-highlight').

This is roughly the sample code from the aforementioned use cases, some functionality is omitted for brevity:

<form id="messageForm" onsubmit="return handleSubmit(event)">
    <textarea id="messageArea" name="message" required placeholder="Type your message here. Use @ to mention users."></textarea>
    <div id="userList"></div>
    <div id="measuringDiv"></div>
    <button type="submit">Submit</button>
</form>
<style>
    ::highlight(syntax-highlight) {
        background-color: rgba(255, 0, 0, 0.3);
        border-radius: 2px;
    }
</style>

Cloning the <textarea> and copying relevant styles from <textarea> to measuring <div> and getting the coordinates to position the popup:

// Copy relevant styles from textarea to measuring div
function copyStyles() {
    const styles = window.getComputedStyle(textarea);
    const relevantStyles = [...];
    relevantStyles.forEach(style => {
        measuringDiv.style[style] = styles[style];
    });
    ensureWordWrapMatches();
}

// Use Case 1

function getCaretCoordinates() {
    const text = textarea.value;
    const caretPos = textarea.selectionStart;
    // Create a copy of the content up to the caret
    const textBeforeCaret = text.substring(0, caretPos);
    // Copy styles before measuring
    copyStyles();
    // Set content and create a range
    measuringDiv.textContent = textBeforeCaret;
    // Add a span where the caret would be
    const caretSpan = document.createElement('span');
    caretSpan.textContent = '|';
    measuringDiv.appendChild(caretSpan);
    // Position the div over the textarea to measure
    measuringDiv.style.visibility = 'hidden';
    measuringDiv.style.position = 'fixed';
    document.body.appendChild(measuringDiv);
    // Get the position of the caret span
    const caretRect = caretSpan.getBoundingClientRect();
    const textareaRect = textarea.getBoundingClientRect();
    // Clean up
    measuringDiv.textContent = '';
    // Return coordinates relative to the viewport
    return {
        left: textareaRect.left + (caretRect.left - measuringDiv.getBoundingClientRect().left),
        top: textareaRect.top + (caretRect.top - measuringDiv.getBoundingClientRect().top),
        height: caretRect.height
    };
}

// Use Case 2

function highlightSyntax(start_index, end_index) {
    const text = textarea.value;
    // Copy styles
    copyStyles();
    // Set content
    measuringDiv.textContent = text;

    // Create range
    const range = document.createRange();
    range.setStart(measuringDiv.firstChild, start_index);
    range.setEnd(measuringDiv.firstChild, end_index);

    // Add highlight
    highlight.add(range);
    
    // Apply highlight
    CSS.highlights.set('syntax-highlight', highlight);
}

textarea.addEventListener('input', (e) => {
    const selectionStart = textarea.selectionStart;
    const text = textarea.value;
    // Check if the last character typed was @ for Use Case 1
    if (text[selectionStart - 1] === '@') {
        const coords = getCaretCoordinates();
        // Position and show the user list
        userList.style.left = `${coords.left}px`;
        userList.style.top = `${coords.top + coords.height}px`;
        userList.style.display = 'block';
        populateUserList();
    } else {
        userList.style.display = 'none';
    }
    
    // Check if the last character typed was ' ' for Use Case 2
    if (text[selectionStart - 1] === ' ' ) {
        if(!dictionary.has(previousWord)){
            highlightSyntax(selectionStart, selectionStart-previousWord.length);
            previousWord = '';
        }
    }
    else {
        previousWord += text[selectionStart - 1];
    }
});


// Initial style copy
copyStyles();
// Initial word setup for highlight
var previousWord = '';
// Dictionary for syntax check
const dictionary = new Set();
// Create highlight object
const highlight = new Highlight();
// Handle window resize
window.addEventListener('resize',copyStyles);

Option 2: Using contenteditable <div>

Using a <div contenteditable> for direct text handling can be challenging. Web authors may need to implement behaviors for form integration, cross-browser consistency, and accessibility to match <textarea> and <input> elements.

This is roughly the sample code from the example above, some functionality is omitted for brevity:

<form id="messageForm" onsubmit="return validateAndSubmit(event)">
    <!-- Hidden input for form validation -->
    <input type="hidden" id="hiddenContent" name="message" required>
    <div contenteditable="true" id="nameField">Type your message here. Use @ to mention users.</div>
    <div id="userList"></div>
    <button type="submit">Submit</button>
</form>

Event listener of contenteditable <div>:

nameField.addEventListener('input', (e) => {
    const selection = document.getSelection();
    const text = nameField.textContent;
    const position = selection.getRangeAt(0).startOffset;
    // Check if the last character typed was @ for Use Case 1
    if (text[position - 1] === '@') {
        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();
        // Position and show the user list
        userList.style.left = `${rect.left}px`;
        userList.style.top = `${rect.bottom + 5}px`;
        userList.style.display = 'block';
        populateUserList();
    } else {
        userList.style.display = 'none';
    }
    
    // Check if the last character typed was ' ' for Use Case 2
    if (text[position - 1] === ' ' ) {
        if(!dictionary.has(previousWord)){
            // Create Range
            const range = document.createRange()
            range.setStart(nameField.firstChild, position);
            range.setEnd(nameField.firstChild, position-previousWord.length);
            previousWord = '';
            // Add highlight
            highlight.add(range);
            // Apply highlight
            CSS.highlights.set('syntax-highlight', highlight);
        }
    }
    else {
        previousWord += text[position - 1];
    }
    // Update hidden input for form validation
    updateHiddenInput();
});

Goal

Provide a way to obtain a FormControlRange—a specialized, live range for <textarea> and <input> values implemented as an AbstractRange subclass—that enables range-based operations (e.g. getting bounding rects, setting custom highlights, etc.) while restricting standard Range mutations to preserve encapsulation of these form controls.

Non-goals

  • Modifying the existing Range API to accommodate the creation of ranges inside <textarea> and <input> contents.
  • Modifying existing functions in the Range API and Highlight API.
  • Modify the behavior of getSelection() or getRangeAt() methods from the Selection API to allow retrieval of <textarea> or <input> ranges.

Proposed Approach

The FormControlRange interface extends AbstractRange and provides a controlled way to reference parts of the value of <textarea> and <input> elements. It exposes useful endpoint properties while limiting certain mutation methods to preserve encapsulation.

Unlike StaticRange, FormControlRange is live — it tracks changes to the underlying text in the <textarea> or <input> element and automatically updates its start and end positions, similar to how a regular Range tracks DOM mutations. This ensures that operations like getBoundingClientRect() or toString() always reflect the element’s current content, even after edits.

This live-update behavior also aligns conceptually with the InputRange() from Keith Cirkel’s Richer Text Fields proposal, which takes a broader approach to enabling richer interactions in form controls. While that proposal covers more editing capabilities, FormControlRange focuses on a limited, encapsulated AbstractRange-based API, but both aim to support dynamic interaction with text as it changes.

Properties and Methods

Properties

FormControlRange exposes useful endpoint information while maintaining encapsulation:

  • startContainer and endContainer: Return the host form control element (<input> or <textarea>). No internal/shadow nodes are exposed.
  • startOffset and endOffset: Return indices into the form control's value. These values match those passed to setFormControlRange() and are automatically updated as text changes.
  • collapsed: Returns whether startOffset equals endOffset.

Available Methods

  • getBoundingClientRect(): Returns the bounding rectangle of the range
  • getClientRects(): Returns a list of rectangles for the range
  • toString(): Returns the text content of the range
  • setFormControlRange(element, startOffset, endOffset): Sets endpoints directly in value space
    • element: Must be a supported text control (<input> or <textarea>)
    • startOffset: Index into element's value where range should start (0 ≤ startOffset ≤ endOffset)
    • endOffset: Index into element's value where range should end (endOffset ≤ element.value.length)
    • Throws IndexSizeError if bounds are invalid
    • Throws NotSupportedError if element is not a supported text control type

Unavailable Methods

The following methods are not available to avoid exposing or mutating inner browser implementation details:

  • setStart(), setEnd() (use setFormControlRange() instead)
  • setStartBefore(), setStartAfter(), setEndBefore(), setEndAfter()
  • selectNode(), selectNodeContents()
  • surroundContents()
  • extractContents()
  • deleteContents()
  • insertNode()
  • cloneContents()
  • cloneRange()

Additional methods can be later introduced progressively based on developer needs.

Furthermore, it is important to reiterate that despite being similar to the regular Range interface, FormControlRange extends AbstractRange and thus can be taken as an argument in methods that allow AbstractRange, such as the Custom Highlight API.

The constructor for FormControlRange is the following:

// Create a FormControlRange instance
const formRange = new FormControlRange();

The instance can be then set using the following method:

// Set the range within a form control
formRange.setFormControlRange(formControl, startOffset, endOffset);

The following sample code showcases how the new FormControlRange interface would solve the main use cases laid out in the User-Facing Problem section.

<form id="messageForm" onsubmit="return handleSubmit(event)">
    <textarea id="messageArea" name="message" required placeholder="Type your message here. Use @ to mention users."></textarea>
    <div id="userList"></div>
    <button type="submit">Submit</button>
</form>

Event listener for <textarea>:

textarea.addEventListener('input', (e) => {
    const selectionStart = textarea.selectionStart;
    const text = textarea.value;
    // Check if the last character typed was @ for Use Case 1
    if (text[selectionStart - 1] === '@') {
        // Create FormControlRange using the proposed API
        const formRange = new FormControlRange();
        formRange.setFormControlRange(textarea, selectionStart, selectionStart);
        // Use the range to obtain the bounding client rect
        const rect = formRange.getBoundingClientRect();
        // Position and show the user list
        userList.style.left = `${rect.left}px`;
        userList.style.top = `${rect.bottom}px`;
        userList.style.display = 'block';
        populateUserList();
    } else {
        userList.style.display = 'none';
    }
    // Check if the last character typed was " " for Use Case 2
    if (text[selectionStart - 1] === ' ') {
        if(!dictionary.has(previousWord)){
            // Create FormControlRange
            const formRange = new FormControlRange();
            formRange.setFormControlRange(textarea, selectionStart-previousWord.length, selectionStart);
            // Add highlight
            highlight.add(formRange);
            // Apply highlight
            CSS.highlights.set('syntax-highlight', highlight);
            previousWord = '';
        }
    }
    else {
        previousWord += text[selectionStart - 1];
    }
});

This implementation simplifies obtaining the caret's position inside <input> and <textarea> elements. It also allows web developers to use the Highlight API directly on those elements. The FormControlRange interface eliminates the need for cloning elements and copying styles, improving performance while maintaining the benefits of using native form controls, such as accessibility, built-in form validation, and consistent behavior across browsers.

As we want the FormControlRange interface to be aligned with the current selection APIs for <textarea> and <input> elements, such as select(), selectionStart, and selectionEnd, the <input> types in which it will be available are listed in the do not apply section:

  • Text
  • Search
  • Telephone
  • URL
  • Password

Following this same alignment with selection APIs, startOffset and endOffset are indices into element.value, matching the units used by selectionStart and selectionEnd.

Sample code for <input type="text">:

<input type="text" id="messageInput" placeholder="Type a message..." />
<div id="emojiPicker"></div>

Event listener for <input>:

input.addEventListener('input', (e) => {
    const selectionStart = input.selectionStart;
    const text = input.value;
    
    // Show emoji picker when user types ':' for Use Case 1
    if (text[selectionStart - 1] === ':') {
        // Create FormControlRange using the proposed API
        const formRange = new FormControlRange();
        formRange.setFormControlRange(input, selectionStart, selectionStart);
        // Use the range to obtain the bounding client rect
        const rect = formRange.getBoundingClientRect();
        // Position the emoji picker under the caret
        emojiPicker.style.position = 'fixed';
        emojiPicker.style.left = `${rect.left}px`;
        emojiPicker.style.top = `${rect.bottom}px`;
        emojiPicker.style.display = 'block';
    } else {
        emojiPicker.style.display = 'none';
    }
    // Check if the last character typed was " " for Use Case 2
    if (text[selectionStart - 1] === ' ') {
        if(!dictionary.has(previousWord)){
            // Create FormControlRange
            const formRange = new FormControlRange();
            formRange.setFormControlRange(input, selectionStart-previousWord.length, selectionStart);
            // Add highlight
            highlight.add(formRange);
            // Apply highlight
            CSS.highlights.set('syntax-highlight', highlight);
            previousWord = '';
        }
    }
    else {
        previousWord += text[selectionStart - 1];
    }
});

Live Range Examples

The examples below demonstrate how FormControlRange updates in real time as the text content of the control changes, without requiring manual offset adjustments.


Example 1: Popup Follows Caret Position

<textarea id="messageArea"></textarea>
<div id="popup">Popup</div>
const textarea = document.querySelector("#messageArea");
const popup = document.querySelector("#popup");

// Create a live, collapsed range at the caret.
const caretRange = new FormControlRange();
caretRange.setFormControlRange(textarea, textarea.selectionStart, textarea.selectionStart);

textarea.addEventListener("input", () => {
  // Position popup under caret.
  const rect = caretRange.getBoundingClientRect();
  popup.style.left = `${rect.left}px`;
  popup.style.top = `${rect.bottom}px`;
});

As the user types, the popup stays positioned under the caret without manually recalculating offsets.

Example 2: Highlight Follows Word Through Edits

<textarea id="messageArea">hello world</textarea>
const textarea = document.querySelector("#messageArea");

// Create a live range covering "hello".
const wordRange = new FormControlRange();
wordRange.setFormControlRange(textarea, 0, 5);

// Bind a highlight to that live range.
const highlight = new Highlight(wordRange);
CSS.highlights.set("tracked-word", highlight);

// No input-handler needed: as the control’s value changes, the range stays in sync and the highlight repaints automatically.

If text is inserted before "hello", the highlight automatically shifts so it still covers the same word.

Alternatives considered

Apart from the solutions described in the User-Facing Problem section, we have also considered the following alternatives:

getSelectionBoundingClientRect()

As one of the main use cases, the original proposal involved obtaining the bounds of a selection within a <textarea> or <div> element through a new API, getSelectionBoundingClientRect(). This new API would mimic the behavior of document.getSelection().getRangeAt(0).getBoundingClientRect(); which can be currently used to obtain a DOMRect object representing the selection bounds within a <div> element. A sample code for an autocomplete menu positioning using this original approach could look as follows:

// Get current selection range in a form control
const input = document.querySelector('input[type="text"]');
const rect = input.getSelectionBoundingClientRect();
// Position autocomplete menu using rect coordinates
showAutocomplete(rect.left, rect.bottom);

This alternative has the following disadvantages:

  1. Redundancy

    • getSelectionBoundingClientRect() overlaps with getSelection().getRangeAt(0).getBoundingClientRect() for every element other than <textarea> and <input>.
  2. Too specific

    • This proposal is not compatible with the Highlight API, or any other Range API method, such as Range.getClientRects().
    • It does not allow for obtaining the bounding rectangle of any text range other than the one currently selected.
    • While this proposal addresses a specific issue with the Range API's interaction with <textarea> and <input> (namely, getBoundingClientRect()), a more general solution—like FormControlRange—could resolve this and other related problems more broadly.

getRangeFromValue()

The following proposal considered adding a getRangeFromValue() method directly to <textarea> and <input> elements that would return a regular Range object. However, this approach held concerns regarding how using certain Range API methods (namely, those mentioned in the Proposed Approach section) with text control elements would result in the exposure of inner browser implementation details through Range attributes like startContainer and endContainer, creating encapsulation concerns.

// getRangeFromValue() sample usage
const range = textarea.getRangeFromValue(startOffset, endOffset);
// This would expose hidden <textarea> nodes
console.log(range.startContainer);

Extending the Range API (1st Approach)

Another considered approach was extending the Range API by allowing setStart() and setEnd() to work when a Range's boundary points are within a form control.

Range.setStart(formControl: HTMLInputElement | HTMLTextAreaElement, offset: number)
Range.setEnd(formControl: HTMLInputElement | HTMLTextAreaElement, offset: number)

Using this approach, a sample code for an autocomplete menu positioning could look as follows:

// Get current selection range in a form control
const input = document.querySelector('input[type="text"]');
const range = document.createRange();
range.setStart(input, input.selectionStart);
range.setEnd(input, input.selectionEnd);
const rect = range.getBoundingClientRect();
// Position autocomplete menu using rect coordinates
showAutocomplete(rect.left, rect.bottom);

While this approach would help solve the two main use cases stated above, FormControlRange was chosen instead because making changes or updating the behavior of setStart() and setEnd() in the Range API could cause issues for websites that rely on the current specified behavior. Additional changes would also be required to certain accessor methods in order to avoid exposing inner implementation details, as mentioned in the getRangeFromValue().

Extending the Range API (2nd Approach)

As previously noted, the first approach regarding the extension of the Range API posed backward compatibility risks due to changes in the behavior of setStart() and setEnd(). It also raised interoperability concerns regarding breaking the encapsulation of the implementation-defined structures of <textarea> and <input>. The second approach avoids one of these issues by not modifying setStart() and setEnd(). Instead, it introduces a new method specifically for setting ranges within <textarea> and <input> elements:

// Existing behavior remains unchanged
const range = new Range();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

// New method for form controls
const formRange = new Range();
formRange.setFormControlRange(textarea, startOffset, endOffset);

This setFormControlRange() method would set a new flag IsFormControl() in the Range interface, that would be then used to regulate which attributes and methods the Range object can access. Continuing the previous example:

range.startContainer        // Returns startNode
formRange.startContainer    // Returns the host <textarea>/<input> element

This approach addresses encapsulation concerns for ranges in form controls. It also offers better compatibility than the FormControlRange interface, since it remains a Range and is compatible at the type level with APIs that involve Range (not only AbstractRange) objects, such as the Selection API. However, Selection behavior for value-space ranges is not defined today and would require additional spec work.

// Using this approach
selection.addRange(formRange)     // Type-checks; actual behavior would need explicit spec rules

// Using FormControlRange interface
selection.addRange(formRange2)    // Throws exception: "parameter 1 is not of type Range."

Ultimately, the FormControlRange interface was chosen instead. While the setFormControlRange() approach offered better type-level compatibility, it did not provide an explicit distinction between regular and form control ranges—both would be of type Range, potentially confusing web authors about why some methods may or may not be available depending on how the Range was set (using setStart() and setEnd() or setFormControlRange()).

Other Considerations

Privacy

  • There are no anticipated privacy concerns.

Security

  • There are no anticipated security concerns.

Consistency

  • The process of creating and setting a FormControlRange object is similar to that of the regular Range interface, which is already familiar to web authors.
const range = new Range();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);

const formRange = new FormControlRange();
formRange.setFormControlRange(element, startOffset, endOffset);

Compatibility

The FormControlRange interface is currently compatible with any API that utilizes AbstractRange objects, such as the Custom Highlight API. However, this means that FormControlRange is not compatible with methods and APIs that expect a regular Range.

To address this limitation, one proposed solution is to introduce a new interface called DynamicRange. This interface would serve as the counterpart to StaticRange and would also extend AbstractRange.

A DynamicRange object would not have a direct use in JavaScript beyond serving as a parent class for Range and FormControlRange. However, common methods such as getBoundingClientRect() and getClientRects()—which are implemented in both Range and FormControlRange—could be moved into DynamicRange to reduce redundancy.

This change would enable APIs like the Selection API to support FormControlRange simply by updating parameter types from Range* new_range to DynamicRange* new_range. Importantly, this would not introduce any backward compatibility issues: existing methods that expect a Range and are not intended to support FormControlRange would continue to function just as before.

The resulting AbstractRange inheritance structure would look like this:

abstractrange-family

Potential Future Work

Extending to Custom Elements and Potential Renaming

It has been discussed that custom elements could also use this API to expose encapsulated ranges, enabling richer editing or selection behaviors while maintaining internal structure.
Such use cases might also prompt revisiting the current FormControlRange name in favor of something broader, such as ElementRange, to better reflect its applicability beyond form controls.

Relationship to CSS Anchor Positioning

As noted in the W3C TAG early design review, some of the positioning use cases addressed by FormControlRange (such as anchoring popups or highlights to caret positions) could also be explored declaratively through future extensions to CSS Anchor Positioning.

While FormControlRange focuses on providing a programmatic mechanism aligned with existing Range APIs, a complementary declarative model in CSS could offer improved performance, reduced scripting overhead, and more consistent accessibility behavior.

Open Questions

How should FormControlRange behave when callers provide reversed offsets (i.e. startOffset > endOffset)?

Consider the following ideas:

  • Throw IndexSizeError.
  • Convert to a collapsed range (by clipping or reordering endpoints).
    • Which direction should the collapse target?
      • Collapse to max(startOffset, endOffset) (matches DOM Range).
      • Collapse to min(startOffset, endOffset).
  • Preserve a backwards range (allow startOffset > endOffset and define direction-aware behavior for text, toString(), and layout methods).

Example:

<input value="abcd">
<script>
const range = new FormControlRange();
range.setFormControlRange(input, 4, 0);
// Candidates:
// - Throw: setFormControlRange throws IndexSizeError
// - Collapse: range.startOffset === range.endOffset === 4
// - Backwards: range.startOffset === 4; range.endOffset === 0
</script>

Note: With reversed endpoints, DOM Range setters collapse them to a single point, whereas Selection preserves directionality (anchor/focus). Collapsing is the current interoperable behavior, but alternatives remain open for discussion.

Is setFormControlRange() redundant with the type name? Would setRange, set, setStartAndEnd, or something else better align with Range/StaticRange naming?

References & acknowledgements

Many thanks for valuable feedback and advice from:

Related Work

  • Richer Text Fields proposal by Keith Cirkel, which takes a broader approach to enabling richer interactions in form controls but shares the goal of supporting dynamic text interaction.