Implement Input type=text UA Shadow DOM (#37065)

Implement Shadow Tree construction for input `type=text`, adding a text
control inner editor container and placeholder container. Subsequently,
due to the changes of the DOM tree structure, the changes will add a new
NodeFlag `IS_TEXT_CONTROL_INNER_EDITOR` to handle the following cases.
- If a mouse click button event hits a text control inner editor, it
will redirect the focus target to its shadow host.
- In text run's construction, the text control inner editor container
queries the selection from its shadow host. This is later used to
resolve caret and selection painting in the display list.

This will be the first step of fixing input `type=text` and other
single-line text input element widgets. Such as, implementing
`::placeholder` selector.



Testing: Existing WPT test and new Servo specific appearance WPT.
Fixes: #36307

---------

Signed-off-by: stevennovaryo <steven.novaryo@gmail.com>
This commit is contained in:
Steven Novaryo 2025-05-30 20:02:10 +08:00 committed by GitHub
parent 578c52fe2b
commit 5580704438
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 635 additions and 36 deletions

View file

@ -1556,11 +1556,14 @@ impl Document {
return;
}
// For a node within a text input UA shadow DOM, delegate the focus target into its shadow host.
// TODO: This focus delegation should be done with shadow DOM delegateFocus attribute.
let target_el = el.find_focusable_shadow_host_if_necessary();
self.begin_focus_transaction();
// Try to focus `el`. If it's not focusable, focus the document
// instead.
// Try to focus `el`. If it's not focusable, focus the document instead.
self.request_focus(None, FocusInitiator::Local, can_gc);
self.request_focus(Some(&*el), FocusInitiator::Local, can_gc);
self.request_focus(target_el.as_deref(), FocusInitiator::Local, can_gc);
}
let dom_event = DomRoot::upcast::<Event>(MouseEvent::for_platform_mouse_event(

View file

@ -1662,6 +1662,27 @@ impl Element {
)
}
/// Returns the focusable shadow host if this is a text control inner editor.
/// This is a workaround for the focus delegation of shadow DOM and should be
/// used only to delegate focusable inner editor of [HTMLInputElement] and
/// [HTMLTextAreaElement].
pub(crate) fn find_focusable_shadow_host_if_necessary(&self) -> Option<DomRoot<Element>> {
if self.is_focusable_area() {
Some(DomRoot::from_ref(self))
} else if self.upcast::<Node>().is_text_control_inner_editor() {
let containing_shadow_host = self.containing_shadow_root().map(|root| root.Host());
assert!(
containing_shadow_host
.as_ref()
.is_some_and(|e| e.is_focusable_area()),
"Containing shadow host is not focusable"
);
containing_shadow_host
} else {
None
}
}
pub(crate) fn is_actually_disabled(&self) -> bool {
let node = self.upcast::<Node>();
match node.type_id() {

View file

@ -101,6 +101,29 @@ const DEFAULT_RESET_VALUE: &str = "Reset";
const PASSWORD_REPLACEMENT_CHAR: char = '●';
const DEFAULT_FILE_INPUT_VALUE: &str = "No file chosen";
#[derive(Clone, JSTraceable, MallocSizeOf)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
/// Contains reference to text control inner editor and placeholder container element in the UA
/// shadow tree for `<input type=text>`. The following is the structure of the shadow tree.
///
/// ```
/// <input type="text">
/// #shadow-root
/// <div id="inner-container">
/// <div id="input-editor"></div>
/// <div id="input-placeholder"></div>
/// </div>
/// </input>
/// ```
// TODO(stevennovaryo): We are trying to use CSS to mimic Chrome and Firefox's layout for the <input> element.
// But, this could be slower in performance and does have some discrepancies. For example,
// they would try to vertically align <input> text baseline with the baseline of other
// TextNode within an inline flow. Another example is the horizontal scroll.
struct InputTypeTextShadowTree {
text_container: Dom<HTMLDivElement>,
placeholder_container: Dom<HTMLDivElement>,
}
#[derive(Clone, JSTraceable, MallocSizeOf)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
/// Contains references to the elements in the shadow tree for `<input type=range>`.
@ -111,10 +134,49 @@ struct InputTypeColorShadowTree {
color_value: Dom<HTMLDivElement>,
}
// FIXME: These styles should be inside UA stylesheet, but it is not possible without internal pseudo element support.
const TEXT_TREE_STYLE: &str = "
#input-editor::selection {
background: rgba(176, 214, 255, 1.0);
color: black;
}
:host:not(:placeholder-shown) #input-placeholder {
visibility: hidden !important
}
#input-editor {
overflow-wrap: normal;
pointer-events: auto;
}
#input-container {
position: relative;
height: 100%;
pointer-events: none;
display: flex;
}
#input-editor, #input-placeholder {
white-space: pre;
margin-block: auto !important;
inset-block: 0 !important;
block-size: fit-content !important;
}
#input-placeholder {
overflow: hidden !important;
position: absolute !important;
color: grey;
pointer-events: none !important;
}
";
#[derive(Clone, JSTraceable, MallocSizeOf)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
#[non_exhaustive]
enum ShadowTree {
Text(InputTypeTextShadowTree),
Color(InputTypeColorShadowTree),
// TODO: Add shadow trees for other input types (range etc) here
}
@ -1071,7 +1133,7 @@ impl HTMLInputElement {
ShadowRootMode::Closed,
false,
false,
false,
true,
SlotAssignmentMode::Manual,
can_gc,
)
@ -1079,6 +1141,92 @@ impl HTMLInputElement {
})
}
fn create_text_shadow_tree(&self, can_gc: CanGc) {
let document = self.owner_document();
let shadow_root = self.shadow_root(can_gc);
Node::replace_all(None, shadow_root.upcast::<Node>(), can_gc);
let inner_container =
HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
inner_container
.upcast::<Element>()
.SetId(DOMString::from("input-container"), can_gc);
shadow_root
.upcast::<Node>()
.AppendChild(inner_container.upcast::<Node>(), can_gc)
.unwrap();
let placeholder_container =
HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
placeholder_container
.upcast::<Element>()
.SetId(DOMString::from("input-placeholder"), can_gc);
inner_container
.upcast::<Node>()
.AppendChild(placeholder_container.upcast::<Node>(), can_gc)
.unwrap();
let text_container = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
text_container
.upcast::<Element>()
.SetId(DOMString::from("input-editor"), can_gc);
text_container
.upcast::<Node>()
.set_text_control_inner_editor();
inner_container
.upcast::<Node>()
.AppendChild(text_container.upcast::<Node>(), can_gc)
.unwrap();
let style = HTMLStyleElement::new(
local_name!("style"),
None,
&document,
None,
ElementCreator::ScriptCreated,
can_gc,
);
// TODO(stevennovaryo): Either use UA stylesheet with internal pseudo element or preemptively parse
// the stylesheet to reduce the costly operation and avoid CSP related error.
style
.upcast::<Node>()
.SetTextContent(Some(DOMString::from(TEXT_TREE_STYLE)), can_gc);
shadow_root
.upcast::<Node>()
.AppendChild(style.upcast::<Node>(), can_gc)
.unwrap();
let _ = self
.shadow_tree
.borrow_mut()
.insert(ShadowTree::Text(InputTypeTextShadowTree {
text_container: text_container.as_traced(),
placeholder_container: placeholder_container.as_traced(),
}));
}
fn text_shadow_tree(&self, can_gc: CanGc) -> Ref<InputTypeTextShadowTree> {
let has_text_shadow_tree = self
.shadow_tree
.borrow()
.as_ref()
.is_some_and(|shadow_tree| matches!(shadow_tree, ShadowTree::Text(_)));
if !has_text_shadow_tree {
self.create_text_shadow_tree(can_gc);
}
let shadow_tree = self.shadow_tree.borrow();
Ref::filter_map(shadow_tree, |shadow_tree| {
let shadow_tree = shadow_tree.as_ref()?;
match shadow_tree {
ShadowTree::Text(text_tree) => Some(text_tree),
_ => None,
}
})
.ok()
.expect("UA shadow tree was not created")
}
fn create_color_shadow_tree(&self, can_gc: CanGc) {
let document = self.owner_document();
let shadow_root = self.shadow_root(can_gc);
@ -1136,27 +1284,53 @@ impl HTMLInputElement {
let shadow_tree = self.shadow_tree.borrow();
Ref::filter_map(shadow_tree, |shadow_tree| {
let shadow_tree = shadow_tree.as_ref()?;
let ShadowTree::Color(color_tree) = shadow_tree;
Some(color_tree)
match shadow_tree {
ShadowTree::Color(color_tree) => Some(color_tree),
_ => None,
}
})
.ok()
.expect("UA shadow tree was not created")
}
fn update_shadow_tree_if_needed(&self, can_gc: CanGc) {
if self.input_type() == InputType::Color {
let color_shadow_tree = self.color_shadow_tree(can_gc);
let mut value = self.Value();
if value.str().is_valid_simple_color_string() {
value.make_ascii_lowercase();
} else {
value = DOMString::from("#000000");
}
let style = format!("background-color: {value}");
color_shadow_tree
.color_value
.upcast::<Element>()
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
match self.input_type() {
InputType::Text => {
let text_shadow_tree = self.text_shadow_tree(can_gc);
let value = self.Value();
// The addition of zero-width space here forces the text input to have an inline formatting
// context that might otherwise be trimmed if there's no text. This is important to ensure
// that the input element is at least as tall as the line gap of the caret:
// <https://drafts.csswg.org/css-ui/#element-with-default-preferred-size>.
//
// This is also used to ensure that the caret will still be rendered when the input is empty.
// TODO: Is there a less hacky way to do this?
let value_text = match value.is_empty() {
false => value,
true => "\u{200B}".into(),
};
text_shadow_tree
.text_container
.upcast::<Node>()
.SetTextContent(Some(value_text), can_gc);
},
InputType::Color => {
let color_shadow_tree = self.color_shadow_tree(can_gc);
let mut value = self.Value();
if value.str().is_valid_simple_color_string() {
value.make_ascii_lowercase();
} else {
value = DOMString::from("#000000");
}
let style = format!("background-color: {value}");
color_shadow_tree
.color_value
.upcast::<Element>()
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
},
_ => {},
}
}
}
@ -1465,22 +1639,29 @@ impl HTMLInputElementMethods<crate::DomTypeHolder> for HTMLInputElement {
fn SetValue(&self, mut value: DOMString, can_gc: CanGc) -> ErrorResult {
match self.value_mode() {
ValueMode::Value => {
// Step 3.
self.value_dirty.set(true);
{
// Step 3.
self.value_dirty.set(true);
// Step 4.
self.sanitize_value(&mut value);
// Step 4.
self.sanitize_value(&mut value);
let mut textinput = self.textinput.borrow_mut();
// Step 5.
if *textinput.single_line_content() != value {
// Steps 1-2
textinput.set_content(value);
let mut textinput = self.textinput.borrow_mut();
// Step 5.
textinput.clear_selection_to_limit(Direction::Forward);
if *textinput.single_line_content() != value {
// Steps 1-2
textinput.set_content(value);
// Step 5.
textinput.clear_selection_to_limit(Direction::Forward);
}
}
// Additionaly, update the placeholder shown state. This is
// normally being done in the attributed mutated. And, being
// done in another scope to prevent borrow checker issues.
self.update_placeholder_shown_state();
},
ValueMode::Default | ValueMode::DefaultOn => {
self.upcast::<Element>()
@ -2063,6 +2244,19 @@ impl HTMLInputElement {
el.set_placeholder_shown_state(has_placeholder && !has_value);
}
// Update the placeholder text in the text shadow tree.
// To increase the performance, we would only do this when it is necessary.
fn update_text_shadow_tree_placeholder(&self, can_gc: CanGc) {
if self.input_type() != InputType::Text {
return;
}
self.text_shadow_tree(can_gc)
.placeholder_container
.upcast::<Node>()
.SetTextContent(Some(self.placeholder.borrow().clone()), can_gc);
}
// https://html.spec.whatwg.org/multipage/#file-upload-state-(type=file)
// Select files by invoking UI or by passed in argument
fn select_files(&self, opt_test_paths: Option<Vec<DOMString>>, can_gc: CanGc) {
@ -2688,8 +2882,11 @@ impl VirtualMethods for HTMLInputElement {
},
}
self.update_text_shadow_tree_placeholder(can_gc);
self.update_placeholder_shown_state();
},
// FIXME(stevennovaryo): This is only reachable by Default and DefaultOn value mode. While others
// are being handled in [Self::SetValue]. Should we merge this two together?
local_name!("value") if !self.value_dirty.get() => {
let value = mutation.new_value(attr).map(|value| (**value).to_owned());
let mut value = value.map_or(DOMString::new(), DOMString::from);
@ -2738,6 +2935,7 @@ impl VirtualMethods for HTMLInputElement {
.extend(attr.value().chars().filter(|&c| c != '\n' && c != '\r'));
}
}
self.update_text_shadow_tree_placeholder(can_gc);
self.update_placeholder_shown_state();
},
local_name!("readonly") => {

View file

@ -230,6 +230,10 @@ bitflags! {
/// Whether this node has a weird parser insertion mode. i.e whether setting innerHTML
/// needs extra work or not
const HAS_WEIRD_PARSER_INSERTION_MODE = 1 << 11;
/// Whether this node serves as the text container for editable content of
/// <input> or <textarea> element.
const IS_TEXT_CONTROL_INNER_EDITOR = 1 << 12;
}
}
@ -697,6 +701,16 @@ impl Node {
self.flags.get().contains(NodeFlags::IS_CONNECTED)
}
pub(crate) fn set_text_control_inner_editor(&self) {
self.set_flag(NodeFlags::IS_TEXT_CONTROL_INNER_EDITOR, true)
}
pub(crate) fn is_text_control_inner_editor(&self) -> bool {
self.flags
.get()
.contains(NodeFlags::IS_TEXT_CONTROL_INNER_EDITOR)
}
/// Returns the type ID of this node.
pub(crate) fn type_id(&self) -> NodeTypeId {
match *self.eventtarget.type_id() {
@ -1579,6 +1593,7 @@ pub(crate) trait LayoutNodeHelpers<'dom> {
fn assigned_slot_for_layout(self) -> Option<LayoutDom<'dom, HTMLSlotElement>>;
fn is_element_for_layout(&self) -> bool;
fn is_text_node_for_layout(&self) -> bool;
unsafe fn get_flag(self, flag: NodeFlags) -> bool;
unsafe fn set_flag(self, flag: NodeFlags, value: bool);
@ -1614,6 +1629,9 @@ pub(crate) trait LayoutNodeHelpers<'dom> {
/// Whether this element is a `<input>` rendered as text or a `<textarea>`.
fn is_text_input(&self) -> bool;
/// Whether this element serve as a container of editable text for a text input.
fn is_text_control_inner_editor(&self) -> bool;
fn text_content(self) -> Cow<'dom, str>;
fn selection(self) -> Option<Range<usize>>;
fn image_url(self) -> Option<ServoUrl>;
@ -1646,6 +1664,11 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
(*self).is::<Element>()
}
fn is_text_node_for_layout(&self) -> bool {
self.type_id_for_layout() ==
NodeTypeId::CharacterData(CharacterDataTypeId::Text(TextTypeId::Text))
}
#[inline]
fn composed_parent_node_ref(self) -> Option<LayoutDom<'dom, Node>> {
let parent = self.parent_node_ref();
@ -1787,8 +1810,8 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
{
let input = self.unsafe_get().downcast::<HTMLInputElement>().unwrap();
// FIXME: All the non-color input types currently render as text
input.input_type() != InputType::Color
// FIXME: All the non-color and non-text input types currently render as text
!matches!(input.input_type(), InputType::Color | InputType::Text)
} else {
type_id ==
NodeTypeId::Element(ElementTypeId::HTMLElement(
@ -1797,6 +1820,10 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
}
}
fn is_text_control_inner_editor(&self) -> bool {
self.unsafe_get().is_text_control_inner_editor()
}
fn text_content(self) -> Cow<'dom, str> {
if let Some(text) = self.downcast::<Text>() {
return text.upcast().data_for_layout().into();
@ -1814,6 +1841,25 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
}
fn selection(self) -> Option<Range<usize>> {
// This node is a text node of a text control inner editor in a <input> or <textarea> element.
// So we should find those corresponding element, and get its selection.
if self.is_text_node_for_layout() &&
self.parent_node_ref()
.is_some_and(|parent| parent.is_text_control_inner_editor())
{
let shadow_root = self.containing_shadow_root_for_layout();
if let Some(containing_shadow_host) = shadow_root.map(|root| root.get_host_for_layout())
{
if let Some(area) = containing_shadow_host.downcast::<HTMLTextAreaElement>() {
return area.selection_for_layout();
}
if let Some(input) = containing_shadow_host.downcast::<HTMLInputElement>() {
return input.selection_for_layout();
}
}
}
if let Some(area) = self.downcast::<HTMLTextAreaElement>() {
return area.selection_for_layout();
}