- WHATWG tracking issue
- CSSWG issue tracking initial discussions
- TPAC 2024 WHATWG meeting minutes where group supports getSelectionBoundingClientRect()
- Issue tracker
It is common for editors to place a popup beneath the user's current caret to provide contextual auto-complete information. Currently, developers resort to cloning the element and copying its style to determine the caret's position in <textarea> and <input> elements, which is difficult to maintain and may have a performance impact on the web application. This proposal aims to solve the problem of obtaining the bounding rectangle of the current text selection or caret position for <textarea> and <input> elements by introducing a new API, getSelectionBoundingClientRect().
This API's main use case is placing a popup near the caret when the user presses a certain key, as shown in the following GIF.
Web developers have two options to implement this today:
If web authors already use a <textarea> and/or an <input> element in their applications, they might:
- Clone the elements as
<div>s, - Copy layout-affecting styles
- Use
<div>to get aRangeand callgetBoundingClientRect().
This is roughly the sample code from the example above, 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>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();
}
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
};
}
textarea.addEventListener('input', (e) => {
const caretPos = textarea.selectionStart;
const text = textarea.value;
// Check if the last character typed was @
if (text[caretPos - 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';
}
});
// Initial style copy
copyStyles();
// Handle window resize
window.addEventListener('resize',copyStyles);Using a <div contenteditable> to handle text directly can pose challenges in some applications. Web authors may need to implement additional behaviors to match those of <textarea> and <input>, such as form integration, consistent behavior across browsers, and accessibility.
This is roughly the sample code from the example above, some functionality is omitted for brevity:
<form id="messageForm" onsubmit="returnvalidateAndSubmit(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 @
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';
}
// Update hidden input for form validation
updateHiddenInput();
});Provide a more direct way of getting the selection bounds inside the <textarea> and <input> elements.
- Provide a more direct way of getting the selection bounds to any other elements that aren't
<textarea>or<input>, as the current Selection and Range APIs can be used to get the bounding client rectangle (getBoundingClientRect()inRange). - Expand the Range API to accommodate obtaining selection bounds inside
<textarea>and<input>elements.
The getSelectionBoundingClientRect() will be introduced to obtain the bounding rectangle of the current text selection inside <textarea> and <input>. The bounding rectangle
is the caret rectangle if the selection is collapsed. If there is no selection in the <textarea> or <input>, it will return an empty rectangle.
The following sample code showcases how the new getSelectionBoundingClientRect() API would solve the main use case 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 caretPos = textarea.selectionStart;
const text = textarea.value;
// Check if the last character typed was @
if (text[caretPos - 1] === '@') {
// Get the caret position using the proposed API
const rect = textarea.getSelectionBoundingClientRect();
// 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';
}
});This implementation simplifies obtaining the caret's position, provides better form integration, and ensures more consistent behavior across browsers. The getSelectionBoundingClientRect() API eliminates the need for cloning elements and copying styles (improving performance) while maintaining the benefits of using native form controls (accessibility, built-in form validation, etc.)
As we want the getSelectionBoundingClientRect() API 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
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 cursorPos = input.selectionStart;
const text = input.value;
// Show emoji picker when user types ':'
if (text[cursorPos - 1] === ':') {
// Get the caret position using the proposed API
const rect = input.getSelectionBoundingClientRect();
// 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';
}
});Apart from the solutions described in the User-Facing Problem section, we have also considered the following alternative:
An alternative approach considered was adding a currentCaretPosition() method to the Element interface:
partial interface Element {
CaretPosition? currentCaretPosition();
};This method would return a CaretPosition object containing information about the currently visible caret of the node if it were an active element. If it didn't have a visible caret, it would return null.
While this approach could work, getSelectionBoundingClientRect() was chosen as the better solution for the following reasons:
-
API Consistency and Integration:
- Follows the same input type restrictions as
selectionStartandselectionEnd(text, search, tel, url, password). - Works with both collapsed (caret) and non-collapsed (range) selections.
- Follows the same state handling rules:
- Returns an empty
DOMRectwhen there is no selection. - Respects the element's disabled state and read-only attributes.
- Follows the established naming pattern of
getBoundingClientRect(), making it familiar to web developers.
- Returns an empty
- Returns a
DOMRect, which is a well-understood interface used throughout the web platform. - Maintains consistency with how selection-related functionality is exposed on form controls:
- Lives directly on the form control elements (
HTMLInputElementandHTMLTextAreaElement) like other selection methods. - Does not require accessing a separate
SelectionorRangeobject unlikecontenteditableelements.
- Lives directly on the form control elements (
- Follows the same input type restrictions as
-
Scope and Specificity:
currentCaretPosition()being onElementwould imply broader functionality than necessary.- Would require implementing the method for all
Elementtypes, even those where it doesn't make sense. - A more general-purpose API isn't needed since selection/range APIs already work well for other elements.
getBoundingClientRect()does not introduce new accessibility concerns as it only exposes information that is already visually available to users.
- The API only exposes caret/selection position information within form controls where such exposure is already available through existing selection APIs.
- Returns coordinates relative to the viewport, consistent with existing
getBoundingClientRect()behavior.
- It is limited in scope as it only works on specific form control types.
- Follows the same security model as existing selection APIs:
- Respects the element's disabled state and read-only attributes.
- Only functions when the element has focus.
- Returns empty rectangle when there is no selection.
- For password fields:
- Selection position information is already available through existing APIs.
- Chromium: Positive
- WebKit: Positive based on whatwg/meta#326 (comment) (API for visible caret position in textarea/input discussion)
- Gecko: Positive based on whatwg/meta#326 (comment) (API for visible caret position in textarea/input discussion)
Concluded that the group supports getSelectionBoundingClientRect() API
Many thanks for valuable feedback and advice from:
