fix(forms): resolve placeholder text through its pseudo style

This commit is contained in:
ldm0
2026-10-13 14:56:35 +08:00
committed by Donough Liu
parent 992b9f1104
commit 900a5e1d3d
4 changed files with 144 additions and 15 deletions
+45 -15
View File
@@ -21,11 +21,12 @@ where
let controls = (0..world.boxes.len())
.map(LayoutBoxId::from_index)
.filter_map(|id| {
form_control_texts(&world.boxes[id.index()].element_semantics).map(|texts| (id, texts))
form_control_texts(&world.boxes[id.index()].element_semantics)
.map(|(texts, placeholder)| (id, texts, placeholder))
})
.filter(|(_, texts)| !texts.is_empty())
.filter(|(_, texts, _)| !texts.is_empty())
.collect::<Vec<_>>();
for (control, texts) in controls {
for (control, texts, placeholder) in controls {
let Some(owner) = world.boxes[control.index()].source else {
continue;
};
@@ -39,11 +40,22 @@ where
.clip_form_control_contents();
}
let owner_label = world.boxes[control.index()].source_label.clone();
let wrapper_style = styles.anonymous_style(
owner,
&world.boxes[control.index()].style,
LayoutDisplay::Block,
)?;
let placeholder_style = if placeholder {
styles.placeholder_style(owner)?
} else {
None
};
let wrapper_style = if let Some(style) = placeholder_style {
let mut style = style.into_style();
style.force_layout_display(LayoutDisplay::Block);
style
} else {
styles.anonymous_style(
owner,
&world.boxes[control.index()].style,
LayoutDisplay::Block,
)?
};
let text_style = ResolvedLayoutStyle::text_leaf_from(&wrapper_style);
for (row, text) in texts.into_iter().enumerate() {
let mut wrapper = LayoutWorld::new_box(
@@ -79,7 +91,7 @@ where
Ok(())
}
fn form_control_texts(semantics: &Option<LayoutElementSemantics>) -> Option<Vec<Arc<str>>> {
fn form_control_texts(semantics: &Option<LayoutElementSemantics>) -> Option<(Vec<Arc<str>>, bool)> {
let semantics = semantics.as_ref()?;
let LayoutElementCategory::FormControl(kind) = semantics.category else {
return None;
@@ -94,8 +106,23 @@ fn form_control_texts(semantics: &Option<LayoutElementSemantics>) -> Option<Vec<
.cloned()
.unwrap_or_default();
if semantics.is_select_listbox() {
return Some(data.option_labels);
return Some((data.option_labels, false));
}
let placeholder = data.value.is_empty()
&& !data.placeholder.is_empty()
&& matches!(
kind,
LayoutFormControlKind::TextArea
| LayoutFormControlKind::Input(
LayoutInputControlKind::Text
| LayoutInputControlKind::Search
| LayoutInputControlKind::Email
| LayoutInputControlKind::Telephone
| LayoutInputControlKind::Password
| LayoutInputControlKind::Url
| LayoutInputControlKind::Number
)
);
let text = match kind {
LayoutFormControlKind::Input(input) => match input {
LayoutInputControlKind::Button => data.value.to_string(),
@@ -161,11 +188,14 @@ fn form_control_texts(semantics: &Option<LayoutElementSemantics>) -> Option<Vec<
| LayoutFormControlKind::Progress
| LayoutFormControlKind::Meter => String::new(),
};
Some(if text.is_empty() {
Vec::new()
} else {
vec![text.into()]
})
Some((
if text.is_empty() {
Vec::new()
} else {
vec![text.into()]
},
placeholder,
))
}
pub(crate) fn select_row_height(font_size: f32, line_height: f32) -> f32 {
+7
View File
@@ -653,6 +653,13 @@ where
Ok(None)
}
fn placeholder_style(
&mut self,
_node: N,
) -> Result<Option<ResolvedLayoutPseudoStyle>, LayoutError> {
Ok(None)
}
fn anonymous_style(
&mut self,
_owner: N,
@@ -204,6 +204,19 @@ impl LayoutStyleResolver<DomHandle> for NativeLayoutStyleResolver<'_> {
Ok(resolved)
}
fn placeholder_style(
&mut self,
node: DomHandle,
) -> Result<Option<ResolvedLayoutPseudoStyle>, LayoutError> {
Ok(self
.reads
.read(node)
.pseudo_computed_values("placeholder")
.map(|computed| {
ResolvedLayoutPseudoStyle::new(ResolvedLayoutStyle::from_stylo(computed))
}))
}
fn anonymous_style(
&mut self,
owner: DomHandle,
@@ -11,6 +11,85 @@ fn dark_pixels(image: &moli_paint::RasterImage, x: u32, y: u32, width: u32, heig
.count()
}
#[tokio::test(flavor = "current_thread")]
async fn placeholder_pseudo_style_tracks_empty_and_filled_form_controls() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let mut page_vm = test_page_vm_with_loader_and_document_url(
&loader,
Vec::new(),
Url::parse("https://example.com/placeholder-paint.html")?,
);
// Like Blink's TextFieldInputType::UpdatePlaceholderText, placeholder
// content has its own pseudo style, independently of the input value.
page_vm.vm_mut().eval(
r#"
document.head.innerHTML = `<style>
html,body{margin:0}
input,textarea{display:block;width:200px;height:40px;padding:0;border:0;
color:red;font:20px/28px sans-serif}
::placeholder{color:rgb(31,63,127);font-size:24px;opacity:0.5}
</style>`;
document.body.innerHTML=`<input placeholder=Example><input type=email placeholder=Example>
<textarea placeholder=Example></textarea>`;
'installed'
"#,
)?;
page_vm.vm_mut().sync_live_document_style_sources();
for (value, expected_color, expected_size) in [
(
"",
moli_layout::PaintColor::new(31.0 / 255.0, 63.0 / 255.0, 127.0 / 255.0, 1.0),
24.0,
),
(
"Example",
moli_layout::PaintColor::new(1.0, 0.0, 0.0, 1.0),
20.0,
),
(
"",
moli_layout::PaintColor::new(31.0 / 255.0, 63.0 / 255.0, 127.0 / 255.0, 1.0),
24.0,
),
] {
page_vm.vm_mut().eval(&format!(
"document.querySelectorAll('input,textarea').forEach(el=>el.value={});'updated'",
serde_json::to_string(value)?,
))?;
let snapshot = page_vm
.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(240, 180, 1.0))?
.expect("placeholder layout");
let runs = snapshot
.fragments
.iter()
.filter_map(|fragment| match fragment {
moli_layout::PaintFragment::GlyphRun(run) => Some(run),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(runs.len(), 3, "each control paints one label");
for run in runs {
assert_eq!(run.color, expected_color, "value={value:?}");
assert_eq!(run.font_size, expected_size);
}
let placeholder_layers = snapshot.fragments.iter().filter(|fragment| matches!(
fragment, moli_layout::PaintFragment::PushLayer { opacity, .. } if *opacity == 0.5
)).count();
assert_eq!(
placeholder_layers,
if value.is_empty() { 3 } else { 0 },
"placeholder opacity must not affect typed text"
);
}
Ok::<_, anyhow::Error>(())
})
.await
.expect("placeholder paint fixture should run");
}
#[tokio::test(flavor = "current_thread")]
async fn screenshot_select_listboxes_paint_every_option_and_clip_their_contents() {
run_page_vm_async_test(async move {