mirror of
https://github.com/lexmount/moli.git
synced 2026-10-01 08:00:38 +00:00
fix(forms): preserve text input programmatic scroll
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/");
|
||||
|
||||
Reference in New Issue
Block a user