Skip to content

Commit 5580704

Browse files
Implement Input type=text UA Shadow DOM (servo#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: servo#36307 --------- Signed-off-by: stevennovaryo <[email protected]>
1 parent 578c52f commit 5580704

24 files changed

+635
-36
lines changed

components/layout/dom_traversal.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,14 @@ impl<'dom> NodeAndStyleInfo<'dom> {
5959
}
6060
}
6161

62+
/// Whether this is a container for the editable text within a single-line text input.
63+
/// This is used to solve the special case of line height for a text editor.
64+
/// <https://html.spec.whatwg.org/multipage/#the-input-element-as-a-text-entry-widget>
65+
// FIXME(stevennovaryo): Now, this would also refer to HTMLInputElement, to handle input
66+
// elements without shadow DOM.
6267
pub(crate) fn is_single_line_text_input(&self) -> bool {
63-
self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement)
68+
self.node.type_id() == LayoutNodeType::Element(LayoutElementType::HTMLInputElement) ||
69+
self.node.is_text_control_inner_editor()
6470
}
6571

6672
pub(crate) fn pseudo(

components/script/dom/document.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1556,11 +1556,14 @@ impl Document {
15561556
return;
15571557
}
15581558

1559+
// For a node within a text input UA shadow DOM, delegate the focus target into its shadow host.
1560+
// TODO: This focus delegation should be done with shadow DOM delegateFocus attribute.
1561+
let target_el = el.find_focusable_shadow_host_if_necessary();
1562+
15591563
self.begin_focus_transaction();
1560-
// Try to focus `el`. If it's not focusable, focus the document
1561-
// instead.
1564+
// Try to focus `el`. If it's not focusable, focus the document instead.
15621565
self.request_focus(None, FocusInitiator::Local, can_gc);
1563-
self.request_focus(Some(&*el), FocusInitiator::Local, can_gc);
1566+
self.request_focus(target_el.as_deref(), FocusInitiator::Local, can_gc);
15641567
}
15651568

15661569
let dom_event = DomRoot::upcast::<Event>(MouseEvent::for_platform_mouse_event(

components/script/dom/element.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,27 @@ impl Element {
16621662
)
16631663
}
16641664

1665+
/// Returns the focusable shadow host if this is a text control inner editor.
1666+
/// This is a workaround for the focus delegation of shadow DOM and should be
1667+
/// used only to delegate focusable inner editor of [HTMLInputElement] and
1668+
/// [HTMLTextAreaElement].
1669+
pub(crate) fn find_focusable_shadow_host_if_necessary(&self) -> Option<DomRoot<Element>> {
1670+
if self.is_focusable_area() {
1671+
Some(DomRoot::from_ref(self))
1672+
} else if self.upcast::<Node>().is_text_control_inner_editor() {
1673+
let containing_shadow_host = self.containing_shadow_root().map(|root| root.Host());
1674+
assert!(
1675+
containing_shadow_host
1676+
.as_ref()
1677+
.is_some_and(|e| e.is_focusable_area()),
1678+
"Containing shadow host is not focusable"
1679+
);
1680+
containing_shadow_host
1681+
} else {
1682+
None
1683+
}
1684+
}
1685+
16651686
pub(crate) fn is_actually_disabled(&self) -> bool {
16661687
let node = self.upcast::<Node>();
16671688
match node.type_id() {

components/script/dom/htmlinputelement.rs

Lines changed: 225 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,29 @@ const DEFAULT_RESET_VALUE: &str = "Reset";
101101
const PASSWORD_REPLACEMENT_CHAR: char = '●';
102102
const DEFAULT_FILE_INPUT_VALUE: &str = "No file chosen";
103103

104+
#[derive(Clone, JSTraceable, MallocSizeOf)]
105+
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
106+
/// Contains reference to text control inner editor and placeholder container element in the UA
107+
/// shadow tree for `<input type=text>`. The following is the structure of the shadow tree.
108+
///
109+
/// ```
110+
/// <input type="text">
111+
/// #shadow-root
112+
/// <div id="inner-container">
113+
/// <div id="input-editor"></div>
114+
/// <div id="input-placeholder"></div>
115+
/// </div>
116+
/// </input>
117+
/// ```
118+
// TODO(stevennovaryo): We are trying to use CSS to mimic Chrome and Firefox's layout for the <input> element.
119+
// But, this could be slower in performance and does have some discrepancies. For example,
120+
// they would try to vertically align <input> text baseline with the baseline of other
121+
// TextNode within an inline flow. Another example is the horizontal scroll.
122+
struct InputTypeTextShadowTree {
123+
text_container: Dom<HTMLDivElement>,
124+
placeholder_container: Dom<HTMLDivElement>,
125+
}
126+
104127
#[derive(Clone, JSTraceable, MallocSizeOf)]
105128
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
106129
/// Contains references to the elements in the shadow tree for `<input type=range>`.
@@ -111,10 +134,49 @@ struct InputTypeColorShadowTree {
111134
color_value: Dom<HTMLDivElement>,
112135
}
113136

137+
// FIXME: These styles should be inside UA stylesheet, but it is not possible without internal pseudo element support.
138+
const TEXT_TREE_STYLE: &str = "
139+
#input-editor::selection {
140+
background: rgba(176, 214, 255, 1.0);
141+
color: black;
142+
}
143+
144+
:host:not(:placeholder-shown) #input-placeholder {
145+
visibility: hidden !important
146+
}
147+
148+
#input-editor {
149+
overflow-wrap: normal;
150+
pointer-events: auto;
151+
}
152+
153+
#input-container {
154+
position: relative;
155+
height: 100%;
156+
pointer-events: none;
157+
display: flex;
158+
}
159+
160+
#input-editor, #input-placeholder {
161+
white-space: pre;
162+
margin-block: auto !important;
163+
inset-block: 0 !important;
164+
block-size: fit-content !important;
165+
}
166+
167+
#input-placeholder {
168+
overflow: hidden !important;
169+
position: absolute !important;
170+
color: grey;
171+
pointer-events: none !important;
172+
}
173+
";
174+
114175
#[derive(Clone, JSTraceable, MallocSizeOf)]
115176
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
116177
#[non_exhaustive]
117178
enum ShadowTree {
179+
Text(InputTypeTextShadowTree),
118180
Color(InputTypeColorShadowTree),
119181
// TODO: Add shadow trees for other input types (range etc) here
120182
}
@@ -1071,14 +1133,100 @@ impl HTMLInputElement {
10711133
ShadowRootMode::Closed,
10721134
false,
10731135
false,
1074-
false,
1136+
true,
10751137
SlotAssignmentMode::Manual,
10761138
can_gc,
10771139
)
10781140
.expect("Attaching UA shadow root failed")
10791141
})
10801142
}
10811143

1144+
fn create_text_shadow_tree(&self, can_gc: CanGc) {
1145+
let document = self.owner_document();
1146+
let shadow_root = self.shadow_root(can_gc);
1147+
Node::replace_all(None, shadow_root.upcast::<Node>(), can_gc);
1148+
1149+
let inner_container =
1150+
HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
1151+
inner_container
1152+
.upcast::<Element>()
1153+
.SetId(DOMString::from("input-container"), can_gc);
1154+
shadow_root
1155+
.upcast::<Node>()
1156+
.AppendChild(inner_container.upcast::<Node>(), can_gc)
1157+
.unwrap();
1158+
1159+
let placeholder_container =
1160+
HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
1161+
placeholder_container
1162+
.upcast::<Element>()
1163+
.SetId(DOMString::from("input-placeholder"), can_gc);
1164+
inner_container
1165+
.upcast::<Node>()
1166+
.AppendChild(placeholder_container.upcast::<Node>(), can_gc)
1167+
.unwrap();
1168+
1169+
let text_container = HTMLDivElement::new(local_name!("div"), None, &document, None, can_gc);
1170+
text_container
1171+
.upcast::<Element>()
1172+
.SetId(DOMString::from("input-editor"), can_gc);
1173+
text_container
1174+
.upcast::<Node>()
1175+
.set_text_control_inner_editor();
1176+
inner_container
1177+
.upcast::<Node>()
1178+
.AppendChild(text_container.upcast::<Node>(), can_gc)
1179+
.unwrap();
1180+
1181+
let style = HTMLStyleElement::new(
1182+
local_name!("style"),
1183+
None,
1184+
&document,
1185+
None,
1186+
ElementCreator::ScriptCreated,
1187+
can_gc,
1188+
);
1189+
// TODO(stevennovaryo): Either use UA stylesheet with internal pseudo element or preemptively parse
1190+
// the stylesheet to reduce the costly operation and avoid CSP related error.
1191+
style
1192+
.upcast::<Node>()
1193+
.SetTextContent(Some(DOMString::from(TEXT_TREE_STYLE)), can_gc);
1194+
shadow_root
1195+
.upcast::<Node>()
1196+
.AppendChild(style.upcast::<Node>(), can_gc)
1197+
.unwrap();
1198+
1199+
let _ = self
1200+
.shadow_tree
1201+
.borrow_mut()
1202+
.insert(ShadowTree::Text(InputTypeTextShadowTree {
1203+
text_container: text_container.as_traced(),
1204+
placeholder_container: placeholder_container.as_traced(),
1205+
}));
1206+
}
1207+
1208+
fn text_shadow_tree(&self, can_gc: CanGc) -> Ref<InputTypeTextShadowTree> {
1209+
let has_text_shadow_tree = self
1210+
.shadow_tree
1211+
.borrow()
1212+
.as_ref()
1213+
.is_some_and(|shadow_tree| matches!(shadow_tree, ShadowTree::Text(_)));
1214+
if !has_text_shadow_tree {
1215+
self.create_text_shadow_tree(can_gc);
1216+
}
1217+
1218+
let shadow_tree = self.shadow_tree.borrow();
1219+
Ref::filter_map(shadow_tree, |shadow_tree| {
1220+
let shadow_tree = shadow_tree.as_ref()?;
1221+
match shadow_tree {
1222+
ShadowTree::Text(text_tree) => Some(text_tree),
1223+
_ => None,
1224+
}
1225+
})
1226+
.ok()
1227+
.expect("UA shadow tree was not created")
1228+
}
1229+
10821230
fn create_color_shadow_tree(&self, can_gc: CanGc) {
10831231
let document = self.owner_document();
10841232
let shadow_root = self.shadow_root(can_gc);
@@ -1136,27 +1284,53 @@ impl HTMLInputElement {
11361284
let shadow_tree = self.shadow_tree.borrow();
11371285
Ref::filter_map(shadow_tree, |shadow_tree| {
11381286
let shadow_tree = shadow_tree.as_ref()?;
1139-
let ShadowTree::Color(color_tree) = shadow_tree;
1140-
Some(color_tree)
1287+
match shadow_tree {
1288+
ShadowTree::Color(color_tree) => Some(color_tree),
1289+
_ => None,
1290+
}
11411291
})
11421292
.ok()
11431293
.expect("UA shadow tree was not created")
11441294
}
11451295

11461296
fn update_shadow_tree_if_needed(&self, can_gc: CanGc) {
1147-
if self.input_type() == InputType::Color {
1148-
let color_shadow_tree = self.color_shadow_tree(can_gc);
1149-
let mut value = self.Value();
1150-
if value.str().is_valid_simple_color_string() {
1151-
value.make_ascii_lowercase();
1152-
} else {
1153-
value = DOMString::from("#000000");
1154-
}
1155-
let style = format!("background-color: {value}");
1156-
color_shadow_tree
1157-
.color_value
1158-
.upcast::<Element>()
1159-
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
1297+
match self.input_type() {
1298+
InputType::Text => {
1299+
let text_shadow_tree = self.text_shadow_tree(can_gc);
1300+
let value = self.Value();
1301+
1302+
// The addition of zero-width space here forces the text input to have an inline formatting
1303+
// context that might otherwise be trimmed if there's no text. This is important to ensure
1304+
// that the input element is at least as tall as the line gap of the caret:
1305+
// <https://drafts.csswg.org/css-ui/#element-with-default-preferred-size>.
1306+
//
1307+
// This is also used to ensure that the caret will still be rendered when the input is empty.
1308+
// TODO: Is there a less hacky way to do this?
1309+
let value_text = match value.is_empty() {
1310+
false => value,
1311+
true => "\u{200B}".into(),
1312+
};
1313+
1314+
text_shadow_tree
1315+
.text_container
1316+
.upcast::<Node>()
1317+
.SetTextContent(Some(value_text), can_gc);
1318+
},
1319+
InputType::Color => {
1320+
let color_shadow_tree = self.color_shadow_tree(can_gc);
1321+
let mut value = self.Value();
1322+
if value.str().is_valid_simple_color_string() {
1323+
value.make_ascii_lowercase();
1324+
} else {
1325+
value = DOMString::from("#000000");
1326+
}
1327+
let style = format!("background-color: {value}");
1328+
color_shadow_tree
1329+
.color_value
1330+
.upcast::<Element>()
1331+
.set_string_attribute(&local_name!("style"), style.into(), can_gc);
1332+
},
1333+
_ => {},
11601334
}
11611335
}
11621336
}
@@ -1465,22 +1639,29 @@ impl HTMLInputElementMethods<crate::DomTypeHolder> for HTMLInputElement {
14651639
fn SetValue(&self, mut value: DOMString, can_gc: CanGc) -> ErrorResult {
14661640
match self.value_mode() {
14671641
ValueMode::Value => {
1468-
// Step 3.
1469-
self.value_dirty.set(true);
1470-
1471-
// Step 4.
1472-
self.sanitize_value(&mut value);
1642+
{
1643+
// Step 3.
1644+
self.value_dirty.set(true);
14731645

1474-
let mut textinput = self.textinput.borrow_mut();
1646+
// Step 4.
1647+
self.sanitize_value(&mut value);
14751648

1476-
// Step 5.
1477-
if *textinput.single_line_content() != value {
1478-
// Steps 1-2
1479-
textinput.set_content(value);
1649+
let mut textinput = self.textinput.borrow_mut();
14801650

14811651
// Step 5.
1482-
textinput.clear_selection_to_limit(Direction::Forward);
1652+
if *textinput.single_line_content() != value {
1653+
// Steps 1-2
1654+
textinput.set_content(value);
1655+
1656+
// Step 5.
1657+
textinput.clear_selection_to_limit(Direction::Forward);
1658+
}
14831659
}
1660+
1661+
// Additionaly, update the placeholder shown state. This is
1662+
// normally being done in the attributed mutated. And, being
1663+
// done in another scope to prevent borrow checker issues.
1664+
self.update_placeholder_shown_state();
14841665
},
14851666
ValueMode::Default | ValueMode::DefaultOn => {
14861667
self.upcast::<Element>()
@@ -2063,6 +2244,19 @@ impl HTMLInputElement {
20632244
el.set_placeholder_shown_state(has_placeholder && !has_value);
20642245
}
20652246

2247+
// Update the placeholder text in the text shadow tree.
2248+
// To increase the performance, we would only do this when it is necessary.
2249+
fn update_text_shadow_tree_placeholder(&self, can_gc: CanGc) {
2250+
if self.input_type() != InputType::Text {
2251+
return;
2252+
}
2253+
2254+
self.text_shadow_tree(can_gc)
2255+
.placeholder_container
2256+
.upcast::<Node>()
2257+
.SetTextContent(Some(self.placeholder.borrow().clone()), can_gc);
2258+
}
2259+
20662260
// https://html.spec.whatwg.org/multipage/#file-upload-state-(type=file)
20672261
// Select files by invoking UI or by passed in argument
20682262
fn select_files(&self, opt_test_paths: Option<Vec<DOMString>>, can_gc: CanGc) {
@@ -2688,8 +2882,11 @@ impl VirtualMethods for HTMLInputElement {
26882882
},
26892883
}
26902884

2885+
self.update_text_shadow_tree_placeholder(can_gc);
26912886
self.update_placeholder_shown_state();
26922887
},
2888+
// FIXME(stevennovaryo): This is only reachable by Default and DefaultOn value mode. While others
2889+
// are being handled in [Self::SetValue]. Should we merge this two together?
26932890
local_name!("value") if !self.value_dirty.get() => {
26942891
let value = mutation.new_value(attr).map(|value| (**value).to_owned());
26952892
let mut value = value.map_or(DOMString::new(), DOMString::from);
@@ -2738,6 +2935,7 @@ impl VirtualMethods for HTMLInputElement {
27382935
.extend(attr.value().chars().filter(|&c| c != '\n' && c != '\r'));
27392936
}
27402937
}
2938+
self.update_text_shadow_tree_placeholder(can_gc);
27412939
self.update_placeholder_shown_state();
27422940
},
27432941
local_name!("readonly") => {

0 commit comments

Comments
 (0)