From 5580704438a81ffbd183c6e3deb5cbc479ad4d0a Mon Sep 17 00:00:00 2001 From: Steven Novaryo <65610990+stevennovaryo@users.noreply.github.com> Date: Fri, 30 May 2025 20:02:10 +0800 Subject: [PATCH] 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 --- components/layout/dom_traversal.rs | 8 +- components/script/dom/document.rs | 9 +- components/script/dom/element.rs | 21 ++ components/script/dom/htmlinputelement.rs | 252 ++++++++++++++++-- components/script/dom/node.rs | 50 +++- components/script/layout_dom/node.rs | 9 +- ...rm-action-src-default-ignored.sub.html.ini | 3 + .../spelling-markers-009.html.ini | 2 + .../spelling-markers-010.html.ini | 2 + tests/wpt/mozilla/meta/MANIFEST.json | 112 ++++++++ .../meta/css/input_placeholder.html.ini | 2 + .../input-text-definite-width-ref.html | 15 ++ .../appearance/input-text-definite-width.html | 14 + .../appearance/input-text-empty-ref.html | 15 ++ .../tests/appearance/input-text-empty.html | 14 + .../input-text-nonempty-placeholder-ref.html | 15 ++ .../input-text-nonempty-placeholder.html | 14 + .../appearance/input-text-overflow-ref.html | 15 ++ .../tests/appearance/input-text-overflow.html | 14 + .../input-text-placeholder-overflow-ref.html | 15 ++ .../input-text-placeholder-overflow.html | 14 + .../input-text-placeholder-ref.html | 15 ++ .../appearance/input-text-placeholder.html | 14 + .../appearance/supports/input-text-ref.css | 27 ++ 24 files changed, 635 insertions(+), 36 deletions(-) create mode 100644 tests/wpt/meta/content-security-policy/form-action/form-action-src-default-ignored.sub.html.ini create mode 100644 tests/wpt/meta/html/editing/editing-0/spelling-and-grammar-checking/spelling-markers-009.html.ini create mode 100644 tests/wpt/meta/html/editing/editing-0/spelling-and-grammar-checking/spelling-markers-010.html.ini create mode 100644 tests/wpt/mozilla/meta/css/input_placeholder.html.ini create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-definite-width-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-definite-width.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-empty-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-empty.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-nonempty-placeholder-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-nonempty-placeholder.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-overflow-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-overflow.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-placeholder-overflow-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-placeholder-overflow.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-placeholder-ref.html create mode 100644 tests/wpt/mozilla/tests/appearance/input-text-placeholder.html create mode 100644 tests/wpt/mozilla/tests/appearance/supports/input-text-ref.css diff --git a/components/layout/dom_traversal.rs b/components/layout/dom_traversal.rs index 8bf6d919fa3..4da6aa48b55 100644 --- a/components/layout/dom_traversal.rs +++ b/components/layout/dom_traversal.rs @@ -59,8 +59,14 @@ impl<'dom> NodeAndStyleInfo<'dom> { } } + /// Whether this is a container for the editable text within a single-line text input. + /// This is used to solve the special case of line height for a text editor. + /// + // FIXME(stevennovaryo): Now, this would also refer to HTMLInputElement, to handle input + // elements without shadow DOM. pub(crate) fn is_single_line_text_input(&self) -> bool { - self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement) + self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement) || + self.node.is_text_control_inner_editor() } pub(crate) fn pseudo( diff --git a/components/script/dom/document.rs b/components/script/dom/document.rs index fd4faf0ee43..68efa07537c 100644 --- a/components/script/dom/document.rs +++ b/components/script/dom/document.rs @@ -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::(MouseEvent::for_platform_mouse_event( diff --git a/components/script/dom/element.rs b/components/script/dom/element.rs index af8dbebd20e..311991804e9 100644 --- a/components/script/dom/element.rs +++ b/components/script/dom/element.rs @@ -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> { + if self.is_focusable_area() { + Some(DomRoot::from_ref(self)) + } else if self.upcast::().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::(); match node.type_id() { diff --git a/components/script/dom/htmlinputelement.rs b/components/script/dom/htmlinputelement.rs index e914ad7f5d1..ee61421dfe8 100644 --- a/components/script/dom/htmlinputelement.rs +++ b/components/script/dom/htmlinputelement.rs @@ -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 ``. The following is the structure of the shadow tree. +/// +/// ``` +/// +/// #shadow-root +///
+///
+///
+///
+/// +/// ``` +// TODO(stevennovaryo): We are trying to use CSS to mimic Chrome and Firefox's layout for the element. +// But, this could be slower in performance and does have some discrepancies. For example, +// they would try to vertically align text baseline with the baseline of other +// TextNode within an inline flow. Another example is the horizontal scroll. +struct InputTypeTextShadowTree { + text_container: Dom, + placeholder_container: Dom, +} + #[derive(Clone, JSTraceable, MallocSizeOf)] #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)] /// Contains references to the elements in the shadow tree for ``. @@ -111,10 +134,49 @@ struct InputTypeColorShadowTree { color_value: Dom, } +// 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::(), can_gc); + + let inner_container = + HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + inner_container + .upcast::() + .SetId(DOMString::from("input-container"), can_gc); + shadow_root + .upcast::() + .AppendChild(inner_container.upcast::(), can_gc) + .unwrap(); + + let placeholder_container = + HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + placeholder_container + .upcast::() + .SetId(DOMString::from("input-placeholder"), can_gc); + inner_container + .upcast::() + .AppendChild(placeholder_container.upcast::(), can_gc) + .unwrap(); + + let text_container = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc); + text_container + .upcast::() + .SetId(DOMString::from("input-editor"), can_gc); + text_container + .upcast::() + .set_text_control_inner_editor(); + inner_container + .upcast::() + .AppendChild(text_container.upcast::(), 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::() + .SetTextContent(Some(DOMString::from(TEXT_TREE_STYLE)), can_gc); + shadow_root + .upcast::() + .AppendChild(style.upcast::(), 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 { + 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::() - .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: + // . + // + // 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::() + .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::() + .set_string_attribute(&local_name!("style"), style.into(), can_gc); + }, + _ => {}, } } } @@ -1465,22 +1639,29 @@ impl HTMLInputElementMethods 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::() @@ -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::() + .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>, 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") => { diff --git a/components/script/dom/node.rs b/components/script/dom/node.rs index 691b3a38f19..cd0a41ab9f6 100644 --- a/components/script/dom/node.rs +++ b/components/script/dom/node.rs @@ -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 + /// or