dom: Textual Input UA Shadow Dom (#37527)

Depend on: 
- https://github.com/servo/servo/pull/37427
- https://github.com/servo/servo/pull/37483

Utilize input `type=text` for the display of all textual input. In
which, consist of
https://html.spec.whatwg.org/#the-input-element-as-a-text-entry-widget
and
https://html.spec.whatwg.org/#the-input-element-as-domain-specific-widgets
inputs.

For `password`, `url`, `tel`, and, `email` input, the appearance of
input container is exactly the same as the `text` input. Other types of
textual input simply extends `text` input by adding extra components
inside the container.

Testing: Servo textual input appearance WPT.

---------

Signed-off-by: stevennovaryo <steven.novaryo@gmail.com>
Signed-off-by: Jo Steven Novaryo <jo.steven.novaryo@huawei.com>
This commit is contained in:
Jo Steven Novaryo 2025-07-25 12:38:14 +08:00 committed by GitHub
parent 1d896699a4
commit 6cd8578f8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 546 additions and 279 deletions

View file

@ -1119,25 +1119,16 @@ impl<'dom> LayoutElementHelpers<'dom> for LayoutDom<'dom, Element> {
));
}
// Textual input, specifically text entry and domain specific input has
// a default preferred size.
//
// <https://html.spec.whatwg.org/multipage/#the-input-element-as-a-text-entry-widget>
// <https://html.spec.whatwg.org/multipage/#the-input-element-as-domain-specific-widgets>
let size = if let Some(this) = self.downcast::<HTMLInputElement>() {
// FIXME(pcwalton): More use of atoms, please!
match self.get_attr_val_for_layout(&ns!(), &local_name!("type")) {
// Not text entry widget
Some("hidden") |
Some("date") |
Some("month") |
Some("week") |
Some("time") |
Some("datetime-local") |
Some("number") |
Some("range") |
Some("color") |
Some("checkbox") |
Some("radio") |
Some("file") |
Some("submit") |
Some("image") |
Some("reset") |
Some("hidden") | Some("range") | Some("color") | Some("checkbox") |
Some("radio") | Some("file") | Some("submit") | Some("image") | Some("reset") |
Some("button") => None,
// Others
_ => match this.size_for_layout() {

View file

@ -104,7 +104,8 @@ 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.
/// shadow tree for `text`, `password`, `url`, `tel`, and `email` input. The following is the
/// structure of the shadow tree.
///
/// ```
/// <input type="text">
@ -115,6 +116,7 @@ const DEFAULT_FILE_INPUT_VALUE: &str = "No file chosen";
/// </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
@ -128,7 +130,7 @@ struct InputTypeTextShadowTree {
#[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>`.
/// Contains references to the elements in the shadow tree for `<input type=color>`.
///
/// The shadow tree consists of a single div with the currently selected color as
/// the background.
@ -219,10 +221,11 @@ pub(crate) enum InputType {
}
impl InputType {
// Note that Password is not included here since it is handled
// slightly differently, with placeholder characters shown rather
// than the underlying value.
fn is_textual(&self) -> bool {
/// Defines which input type that should perform like a text input,
/// specifically when it is interacting with JS. Note that Password
/// is not included here since it is handled slightly differently,
/// with placeholder characters shown rather than the underlying value.
pub(crate) fn is_textual(&self) -> bool {
matches!(
*self,
InputType::Date |
@ -1238,9 +1241,35 @@ impl HTMLInputElement {
.expect("UA shadow tree was not created")
}
fn update_text_shadow_tree_if_needed(&self, can_gc: CanGc) {
// Should only do this for `type=text` input.
debug_assert_eq!(self.input_type(), InputType::Text);
/// Should this input type render as a basic text UA widget.
// TODO(#38251): Ideally, the most basic shadow dom should cover only `text`, `password`, `url`, `tel`,
// and `email`. But we are leaving the others textual inputs here while tackling them one
// by one.
pub(crate) fn is_textual_widget(&self) -> bool {
matches!(
self.input_type(),
InputType::Date |
InputType::DatetimeLocal |
InputType::Email |
InputType::Month |
InputType::Number |
InputType::Password |
InputType::Range |
InputType::Search |
InputType::Tel |
InputType::Text |
InputType::Time |
InputType::Url |
InputType::Week
)
}
/// Construct the most basic shadow tree structure for textual input.
/// TODO(stevennovaryo): The rest of textual input shadow dom structure should act like an
/// exstension to this one.
fn update_textual_shadow_tree(&self, can_gc: CanGc) {
// Should only do this for textual input widget.
debug_assert!(self.is_textual_widget());
let text_shadow_tree = self.text_shadow_tree(can_gc);
let value = self.Value();
@ -1253,9 +1282,15 @@ impl HTMLInputElement {
// This is also used to ensure that the caret will still be rendered when the input is empty.
// TODO: Could append `<br>` element to prevent collapses and avoid this hack, but we would
// need to fix the rendering of caret beforehand.
let value_text = match value.is_empty() {
false => value,
true => "\u{200B}".into(),
let value_text = match (value.is_empty(), self.input_type()) {
// For a password input, we replace all of the character with its replacement char.
(false, InputType::Password) => value
.chars()
.map(|_| PASSWORD_REPLACEMENT_CHAR)
.collect::<String>()
.into(),
(false, _) => value,
(true, _) => "\u{200B}".into(),
};
// FIXME(stevennovaryo): Refactor this inside a TextControl wrapper
@ -1269,7 +1304,7 @@ impl HTMLInputElement {
.SetData(value_text);
}
fn update_color_shadow_tree_if_needed(&self, can_gc: CanGc) {
fn update_color_shadow_tree(&self, can_gc: CanGc) {
// Should only do this for `type=color` input.
debug_assert_eq!(self.input_type(), InputType::Color);
@ -1287,10 +1322,10 @@ impl HTMLInputElement {
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
}
fn update_shadow_tree_if_needed(&self, can_gc: CanGc) {
fn update_shadow_tree(&self, can_gc: CanGc) {
match self.input_type() {
InputType::Text => self.update_text_shadow_tree_if_needed(can_gc),
InputType::Color => self.update_color_shadow_tree_if_needed(can_gc),
_ if self.is_textual_widget() => self.update_textual_shadow_tree(can_gc),
InputType::Color => self.update_color_shadow_tree(can_gc),
_ => {},
}
}
@ -1317,10 +1352,6 @@ impl<'dom> LayoutDom<'dom, HTMLInputElement> {
unsafe { self.unsafe_get().filelist.get_inner_as_layout() }
}
fn placeholder(self) -> &'dom str {
unsafe { self.unsafe_get().placeholder.borrow_for_layout() }
}
fn input_type(self) -> InputType {
self.unsafe_get().input_type.get()
}
@ -1336,6 +1367,9 @@ impl<'dom> LayoutDom<'dom, HTMLInputElement> {
}
impl<'dom> LayoutHTMLInputElementHelpers<'dom> for LayoutDom<'dom, HTMLInputElement> {
/// In the past, we are handling the display of <input> element inside the dom tree traversal.
/// With the introduction of shadow DOM, these implementations will be replaced one by one
/// and these will be obselete,
fn value_for_layout(self) -> Cow<'dom, str> {
fn get_raw_attr_value<'dom>(
input: LayoutDom<'dom, HTMLInputElement>,
@ -1349,7 +1383,9 @@ impl<'dom> LayoutHTMLInputElementHelpers<'dom> for LayoutDom<'dom, HTMLInputElem
}
match self.input_type() {
InputType::Checkbox | InputType::Radio | InputType::Image => "".into(),
InputType::Checkbox | InputType::Radio | InputType::Image | InputType::Hidden => {
"".into()
},
InputType::File => {
let filelist = self.get_filelist();
match filelist {
@ -1372,31 +1408,23 @@ impl<'dom> LayoutHTMLInputElementHelpers<'dom> for LayoutDom<'dom, HTMLInputElem
InputType::Button => get_raw_attr_value(self, ""),
InputType::Submit => get_raw_attr_value(self, DEFAULT_SUBMIT_VALUE),
InputType::Reset => get_raw_attr_value(self, DEFAULT_RESET_VALUE),
InputType::Password => {
let text = self.get_raw_textinput_value();
if !text.is_empty() {
text.chars()
.map(|_| PASSWORD_REPLACEMENT_CHAR)
.collect::<String>()
.into()
} else {
self.placeholder().into()
}
},
InputType::Color => {
unreachable!("Input type color is explicitly not rendered as text");
},
// FIXME(#22728): input `type=range` has yet to be implemented.
InputType::Range => "".into(),
_ => {
let text = self.get_raw_textinput_value();
if !text.is_empty() {
text.into()
} else {
self.placeholder().into()
}
unreachable!("Input with shadow tree should use internal shadow tree for layout");
},
}
}
/// Textual input, specifically text entry and domain specific input has
/// a default preferred size.
///
/// <https://html.spec.whatwg.org/multipage/#the-input-element-as-a-text-entry-widget>
/// <https://html.spec.whatwg.org/multipage/#the-input-element-as-domain-specific-widgets>
// FIXME(stevennovaryo): Implement the calculation of default preferred size
// for domain specific input widgets correctly.
// FIXME(#4378): Implement the calculation of average character width for
// textual input correctly.
fn size_for_layout(self) -> u32 {
self.unsafe_get().size.get()
}
@ -2228,7 +2256,7 @@ impl HTMLInputElement {
// 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 {
if !self.is_textual_widget() {
return;
}
@ -2690,7 +2718,7 @@ impl HTMLInputElement {
fn value_changed(&self, can_gc: CanGc) {
self.update_related_validity_states(can_gc);
self.update_shadow_tree_if_needed(can_gc);
self.update_shadow_tree(can_gc);
}
/// <https://html.spec.whatwg.org/multipage/#show-the-picker,-if-applicable>
@ -3034,7 +3062,7 @@ impl VirtualMethods for HTMLInputElement {
// WHATWG-specified activation behaviors are handled elsewhere;
// this is for all the other things a UI click might do
//TODO: set the editing position for text inputs
//TODO(#10083): set the editing position for text inputs
if self.input_type().is_textual_or_password() &&
// Check if we display a placeholder. Layout doesn't know about this.

View file

@ -1852,6 +1852,9 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
}
}
/// Whether this element should layout as a special case input element.
// TODO(#38251): With the implementation of Shadow DOM, we could implement the construction properly
// in the DOM, instead of delegating it to layout.
fn is_text_input(&self) -> bool {
let type_id = self.type_id_for_layout();
if type_id ==
@ -1861,8 +1864,7 @@ impl<'dom> LayoutNodeHelpers<'dom> for LayoutDom<'dom, Node> {
{
let input = self.unsafe_get().downcast::<HTMLInputElement>().unwrap();
// FIXME: All the non-color and non-text input types currently render as text
!matches!(input.input_type(), InputType::Color | InputType::Text)
!input.is_textual_widget() && input.input_type() != InputType::Color
} else {
type_id ==
NodeTypeId::Element(ElementTypeId::HTMLElement(

View file

@ -902,7 +902,6 @@ impl<T: ClipboardProvider> TextInput<T> {
KeyReaction::RedrawSelection
})
.shortcut(CMD_OR_CONTROL, 'X', || {
// FIXME: this is unreachable because ClipboardEvent is fired instead of keydown
if let Some(text) = self.get_selection_text() {
self.clipboard_provider.set_text(text);
self.delete_char(Direction::Backward);
@ -910,6 +909,7 @@ impl<T: ClipboardProvider> TextInput<T> {
KeyReaction::DispatchInput
})
.shortcut(CMD_OR_CONTROL, 'C', || {
// TODO(stevennovaryo): we should not provide text to clipboard for type=password
if let Some(text) = self.get_selection_text() {
self.clipboard_provider.set_text(text);
}