fix(forms): preserve text input programmatic scroll

This commit is contained in:
ldm0
2026-09-13 16:57:18 +08:00
parent b7c828cdf8
commit 27cd7ffa1c
7 changed files with 118 additions and 22 deletions
+1 -1
View File
@@ -433,7 +433,7 @@ where
} else if world.is_viewport_defining_body(id) {
false
} else {
world.boxes[id.index()].style.establishes_scroll_container()
world.boxes[id.index()].establishes_scroll_container()
}
}
+1 -3
View File
@@ -326,9 +326,7 @@ where
} else if self.world.is_viewport_defining_body(id) {
false
} else {
self.world.boxes[id.index()]
.style
.establishes_scroll_container()
self.world.boxes[id.index()].establishes_scroll_container()
}
}
+15
View File
@@ -202,6 +202,13 @@ impl LayoutInputControlKind {
Self::Week => "form-input-week",
}
}
pub(crate) const fn has_single_line_text_editor(self) -> bool {
matches!(
self,
Self::Email | Self::Password | Self::Search | Self::Telephone | Self::Text | Self::Url
)
}
}
/// Replaced-content family. Pixel resources are deliberately not part of this type.
@@ -355,6 +362,14 @@ impl LayoutElementSemantics {
)
}
pub(crate) const fn has_internal_text_control_scroller(&self) -> bool {
matches!(
self.category,
LayoutElementCategory::FormControl(LayoutFormControlKind::Input(kind))
if kind.has_single_line_text_editor()
)
}
/// Whether `display: contents` has a used value of `display: none`.
///
/// This is the unusual-elements list from CSS Display's HTML appendix:
+1 -2
View File
@@ -803,8 +803,7 @@ where
let mut ancestor = world.boxes[id.index()].parent;
while let Some(candidate) = ancestor {
let layout_box = &world.boxes[candidate.index()];
if !world.is_viewport_defining_body(candidate)
&& layout_box.style.establishes_scroll_container()
if !world.is_viewport_defining_body(candidate) && layout_box.establishes_scroll_container()
{
let layout = layout_box.unrounded_layout;
let origin = unrounded_global_origin(world, candidate);
+8
View File
@@ -572,6 +572,14 @@ impl<N> LayoutBox<N> {
.is_some_and(LayoutElementSemantics::is_replaced)
}
pub(crate) fn establishes_scroll_container(&self) -> bool {
self.style.establishes_scroll_container()
|| self
.element_semantics
.as_ref()
.is_some_and(LayoutElementSemantics::has_internal_text_control_scroller)
}
/// Resolve the used ratio at the layout-node boundary, after both authored
/// style and natural replaced-element sizing are available.
pub(crate) fn resolved_aspect_ratio(&self) -> Option<taffy::ResolvedAspectRatio> {
@@ -62,25 +62,38 @@ fn node_scroll_position_setter_for_object<'s>(
}
let runtime = unsafe { &mut *runtime_ptr };
let (minimum, maximum) = if runtime.layout_policy().uses_real_layout() {
observable_element_metrics(
let mut metrics = observable_element_metrics(
runtime,
handle,
moli_layout::LayoutFlushReason::SynchronousGeometry,
)?
.map(|metrics| {
if horizontal {
(
f64::from(metrics.minimum_scroll_offset.x),
f64::from(metrics.maximum_scroll_offset.x),
)
} else {
(
f64::from(metrics.minimum_scroll_offset.y),
f64::from(metrics.maximum_scroll_offset.y),
)
}
})
.unwrap_or((0.0, 0.0))
)?;
// Ordinary geometry getters intentionally share the latest frozen
// tree across same-task DOM mutations. A newly connected receiver can
// therefore be absent from that tree, but a scroll setter must clamp
// against its current range instead of treating it as non-scrollable.
if metrics.is_none() && runtime.dom_host().is_connected(handle) {
runtime.invalidate_layout_after_interaction_state_change();
metrics = observable_element_metrics(
runtime,
handle,
moli_layout::LayoutFlushReason::SynchronousGeometry,
)?;
}
metrics
.map(|metrics| {
if horizontal {
(
f64::from(metrics.minimum_scroll_offset.x),
f64::from(metrics.maximum_scroll_offset.x),
)
} else {
(
f64::from(metrics.minimum_scroll_offset.y),
f64::from(metrics.maximum_scroll_offset.y),
)
}
})
.unwrap_or((0.0, 0.0))
} else {
// Mock intentionally preserves the old synthetic geometry behavior:
// non-negative scroll values are stored even without real overflow.
@@ -1285,6 +1285,69 @@ fn classic_scrollbar_metrics_and_thumb_drag_match_chromium_without_dom_mouse_eve
assert_eq!(result["events"], serde_json::json!([]));
}
#[test]
fn single_line_text_input_preserves_programmatic_scroll_across_select() {
let mut vm = new_storage_test_vm("https://text-input-scroll.test/");
vm.eval(
r#"
(() => {
if (!document.documentElement) {
document.appendChild(document.createElement("html"));
}
if (!document.body) {
document.documentElement.appendChild(document.createElement("body"));
}
const previous = document.createElement("input");
previous.value = "0123456789".repeat(100);
document.body.append(previous);
globalThis.__previousScrollInput = previous;
return "installed";
})()
"#,
)
.expect("text input scroll fixture should initialize");
refresh_layout_for_test(&mut vm);
vm.eval("__previousScrollInput.scrollLeft")
.expect("the previous input should retain the frozen layout");
vm.eval(
r#"
(() => {
__previousScrollInput.remove();
const input = document.createElement("input");
input.value = "0123456789".repeat(100);
document.body.append(input);
globalThis.__scrollInput = input;
return "replaced";
})()
"#,
)
.expect("the previous text input should be replaced");
vm.eval("__scrollInput.scrollLeft = 33")
.expect("text input should accept a programmatic scroll");
refresh_layout_for_test(&mut vm);
assert_eq!(
vm.eval("__scrollInput.scrollWidth > __scrollInput.clientWidth")
.expect("text input overflow should be observable"),
"true"
);
assert_eq!(
vm.eval("__scrollInput.scrollLeft")
.expect("text input scroll should remain observable"),
"33"
);
vm.eval("__scrollInput.select()")
.expect("selecting the input contents should succeed");
refresh_layout_for_test(&mut vm);
assert_eq!(
vm.eval("__scrollInput.scrollLeft")
.expect("selection should preserve the text input scroll"),
"33"
);
}
#[test]
fn painted_overlay_wins_over_scrollbar_and_corner_consumes_input() {
let mut vm = new_storage_test_vm("https://painted-scrollbar-surface.test/");