script: Implement <input type=range> widget's layout (#41562)

Implement the layout of `<input type="range">`. Support automatically
calculating the position of thumb based on the current input value, and
the size of range-track and range-thumb. Added a
document.has_script_or_layout_blocker function to detect whether it is
possible to run box_area_query during bind_to_tree, add delay_task if
there are blocker.

Here are some of the fixes made in this PR:
1. Fixed the structure for input type range's pseudo elements.
2. Fixed the positioning of input type range's thumb based on current
value, width, and direction.
3. Allow input type range to stretch vertically in a bigger container.

Original PR: #41024
Stylo PR: https://github.com/servo/stylo/pull/310

Testing: Some improvements in WPT tests, with a few regressions. This
change includes a Servo-specific appearance test to detect unexpected
changes to the look and feel of range widgets.
Fixes: #22728

---------

Signed-off-by: Budiman Arbenta <arbenta6@gmail.com>
Signed-off-by: Martin Robinson <mrobinson@igalia.com>
Co-authored-by: rayguo17 <tin.tun.aung1@huawei.com>
Co-authored-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Budiman Arbenta
2026-03-31 04:20:58 +08:00
committed by GitHub
parent 399c8ebe31
commit 35c3731c46
18 changed files with 398 additions and 45 deletions

22
Cargo.lock generated
View File

@@ -7270,7 +7270,7 @@ dependencies = [
[[package]]
name = "selectors"
version = "0.36.1"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"bitflags 2.11.0",
"cssparser",
@@ -8905,7 +8905,7 @@ dependencies = [
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"serde",
"stable_deref_trait",
@@ -9325,7 +9325,7 @@ dependencies = [
[[package]]
name = "stylo"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"app_units",
"arrayvec",
@@ -9381,7 +9381,7 @@ dependencies = [
[[package]]
name = "stylo_atoms"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"string_cache",
"string_cache_codegen",
@@ -9390,7 +9390,7 @@ dependencies = [
[[package]]
name = "stylo_derive"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"darling",
"proc-macro2",
@@ -9402,7 +9402,7 @@ dependencies = [
[[package]]
name = "stylo_dom"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"bitflags 2.11.0",
"stylo_malloc_size_of",
@@ -9411,7 +9411,7 @@ dependencies = [
[[package]]
name = "stylo_malloc_size_of"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"app_units",
"cssparser",
@@ -9428,12 +9428,12 @@ dependencies = [
[[package]]
name = "stylo_static_prefs"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
[[package]]
name = "stylo_traits"
version = "0.14.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"app_units",
"bitflags 2.11.0",
@@ -9855,7 +9855,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "to_shmem"
version = "0.3.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"cssparser",
"servo_arc",
@@ -9868,7 +9868,7 @@ dependencies = [
[[package]]
name = "to_shmem_derive"
version = "0.1.0"
source = "git+https://github.com/servo/stylo?rev=938e58caf26b6d0437291ce875dd5b0dd8a52d4f#938e58caf26b6d0437291ce875dd5b0dd8a52d4f"
source = "git+https://github.com/servo/stylo?rev=8557228b96c0e343764953e72a62ea503baf01b3#8557228b96c0e343764953e72a62ea503baf01b3"
dependencies = [
"darling",
"proc-macro2",

View File

@@ -160,12 +160,12 @@ rustls-platform-verifier = "0.6.2"
sea-query = { version = "1.0.0-rc.31", default-features = false, features = ["backend-sqlite", "derive"] }
sea-query-rusqlite = { version = "0.8.0-rc.15" }
sec1 = "0.7"
selectors = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
selectors = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
serde = "1.0.228"
serde_bytes = "0.11"
serde_core = "1.0.226"
serde_json = "1.0"
servo_arc = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
servo_arc = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
sha1 = "0.10"
sha2 = "0.10"
sha3 = "0.10"
@@ -173,12 +173,12 @@ skrifa = "0.37.0"
smallvec = { version = "1.15", features = ["serde", "union"] }
string_cache = "0.9"
strum = { version = "0.28", features = ["derive"] }
stylo = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo_atoms = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo_dom = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo_malloc_size_of = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo_static_prefs = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo_traits = { git = "https://github.com/servo/stylo", rev = "938e58caf26b6d0437291ce875dd5b0dd8a52d4f" }
stylo = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
stylo_atoms = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
stylo_dom = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
stylo_malloc_size_of = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
stylo_static_prefs = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
stylo_traits = { git = "https://github.com/servo/stylo", rev = "8557228b96c0e343764953e72a62ea503baf01b3" }
surfman = { version = "0.11.0", features = ["chains"] }
syn = { version = "2", default-features = false, features = ["clone-impls", "derive", "parsing"] }
synstructure = "0.13"

View File

@@ -128,9 +128,10 @@ input[type="file"]:active::file-selector-button {
button:focus,
select:focus,
input[type="button"]:focus,
input[type="submit"]:focus,
input[type="reset"]:focus,
input[type="color"]:focus,
input[type="range"]:focus,
input[type="reset"]:focus,
input[type="submit"]:focus,
input[type="file"]:focus::file-selector-button {
outline: none;
}
@@ -175,6 +176,73 @@ input::color-swatch {
width: 100%;
}
/* Range inputs */
input[type="range"] {
margin-inline: 8px;
width: 100px;
min-height: 16px;
padding: 0px;
position: relative;
background: initial;
border: initial;
}
input[type="range"]::slider-track,
input[type="range"]::slider-fill,
input[type="range"]::slider-thumb {
/* All slider elements are centered vertically and use the default cursor. */
top: 50%;
transform: translateY(-50%);
position: absolute;
cursor: default;
}
input[type="range"]::slider-track,
input[type="range"]::slider-fill {
border-radius: 5px;
border-width: 1px;
border-style: solid;
}
input[type="range"]::slider-track {
width: 100%;
height: 4px;
background-color: ButtonFace;
border-color: ButtonBorder;
}
input[type="range"]:hover::slider-track {
background: lightgrey;
}
input[type="range"]:active::slider-track {
border-color: grey;
background: gainsboro;
}
input[type="range"]::slider-fill {
height: 4px;
background-color: #007aff;
border-color: #007aff;
}
input[type="range"]::slider-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #007aff;
/* Vertically centered, adjust inline direction by width so thumb
is centered on range value. */
transform: translate(-8px, -50%);
}
input[type="range"]:disabled::slider-fill,
input[type="range"]:disabled::slider-thumb {
background-color: rgba(180, 180, 180);
border-color: rgba(180, 180, 180);
}
/* Checkbox and radio button inputs */
input[type="checkbox"],
input[type="radio"] {
@@ -284,7 +352,7 @@ canvas, embed, iframe, img, video {
overflow-clip-margin: 0 !important;
}
input:not([type=radio i], [type=checkbox i], [type=reset i], [type=button i], [type=submit i], [type=file i]) {
input:not([type=radio i], [type=checkbox i], [type=reset i], [type=button i], [type=submit i], [type=file i], [type=range i]) {
cursor: text;
overflow: hidden !important;
white-space: pre;

View File

@@ -185,7 +185,6 @@ impl InputType {
Self::Hidden(_) |
Self::Month(_) |
Self::Number(_) |
Self::Range(_) |
Self::Search(_) |
Self::Tel(_) |
Self::Text(_) |

View File

@@ -838,7 +838,6 @@ impl HTMLInputElement {
InputType::Month(_) |
InputType::Number(_) |
InputType::Password(_) |
InputType::Range(_) |
InputType::Search(_) |
InputType::Tel(_) |
InputType::Text(_) |

View File

@@ -1,19 +1,56 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
use std::cell::Ref;
use html5ever::{local_name, ns};
use js::context::JSContext;
use markup5ever::QualName;
use script_bindings::codegen::GenericBindings::HTMLInputElementBinding::HTMLInputElementMethods;
use script_bindings::domstring::parse_floating_point_number;
use script_bindings::root::Dom;
use script_bindings::script_runtime::CanGc;
use style::selector_parser::PseudoElement;
use crate::dom::bindings::cell::DomRefCell;
use crate::dom::bindings::codegen::Bindings::NodeBinding::NodeMethods;
use crate::dom::bindings::inheritance::Castable;
use crate::dom::bindings::str::DOMString;
use crate::dom::htmlinputelement::text_input_widget::TextInputWidget;
use crate::dom::element::{CustomElementCreationMode, Element, ElementCreator};
use crate::dom::input_element::HTMLInputElement;
use crate::dom::input_element::input_type::SpecificInputType;
use crate::dom::node::{Node, NodeTraits};
#[derive(Default, JSTraceable, MallocSizeOf, PartialEq)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
pub(crate) struct RangeInputType {
text_input_widget: DomRefCell<TextInputWidget>,
shadow_tree: DomRefCell<Option<RangeInputShadowTree>>,
}
impl RangeInputType {
/// Get the shadow tree for this [`HTMLInputElement`], if it is created and valid, otherwise
/// recreate the shadow tree and return it.
fn get_or_create_shadow_tree(
&self,
cx: &mut JSContext,
input: &HTMLInputElement,
) -> Ref<'_, RangeInputShadowTree> {
{
if let Ok(shadow_tree) = Ref::filter_map(self.shadow_tree.borrow(), |shadow_tree| {
shadow_tree.as_ref()
}) {
return shadow_tree;
}
}
let element = input.upcast::<Element>();
let shadow_root = element
.shadow_root()
.unwrap_or_else(|| element.attach_ua_shadow_root(cx, true));
let shadow_root = shadow_root.upcast();
*self.shadow_tree.borrow_mut() = Some(RangeInputShadowTree::new(cx, shadow_root));
self.get_or_create_shadow_tree(cx, input)
}
}
impl SpecificInputType for RangeInputType {
@@ -87,15 +124,7 @@ impl SpecificInputType for RangeInputType {
}
fn update_shadow_tree(&self, cx: &mut JSContext, input: &HTMLInputElement) {
self.text_input_widget
.borrow()
.update_shadow_tree(cx, input)
}
fn update_placeholder_contents(&self, cx: &mut JSContext, input: &HTMLInputElement) {
self.text_input_widget
.borrow()
.update_placeholder_contents(cx, input)
self.get_or_create_shadow_tree(cx, input).update(cx, input)
}
}
@@ -109,3 +138,114 @@ fn round_halves_positive(n: f64) -> f64 {
n.round()
}
}
#[derive(Clone, JSTraceable, MallocSizeOf, PartialEq)]
#[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
/// Contains references to the elements in the shadow tree for `<input type=range>`.
///
/// The shadow tree consists of three div in the following structure:
/// <input type=range>
/// ├─ ::slider-track
/// │ └─ ::slider-fill
/// └─ ::slider-thumb
pub(crate) struct RangeInputShadowTree {
slider_fill: Dom<Element>,
slider_thumb: Dom<Element>,
slider_track: Dom<Element>,
}
impl RangeInputShadowTree {
pub(crate) fn new(cx: &mut JSContext, shadow_root: &Node) -> Self {
Node::replace_all(cx, None, shadow_root.upcast::<Node>());
let slider_fill = Element::create(
cx,
QualName::new(None, ns!(html), local_name!("div")),
None,
&shadow_root.owner_document(),
ElementCreator::ScriptCreated,
CustomElementCreationMode::Asynchronous,
None,
);
let slider_thumb = Element::create(
cx,
QualName::new(None, ns!(html), local_name!("div")),
None,
&shadow_root.owner_document(),
ElementCreator::ScriptCreated,
CustomElementCreationMode::Asynchronous,
None,
);
let slider_track = Element::create(
cx,
QualName::new(None, ns!(html), local_name!("div")),
None,
&shadow_root.owner_document(),
ElementCreator::ScriptCreated,
CustomElementCreationMode::Asynchronous,
None,
);
shadow_root
.upcast::<Node>()
.AppendChild(cx, slider_track.upcast::<Node>())
.unwrap();
slider_track
.upcast::<Node>()
.AppendChild(cx, slider_fill.upcast::<Node>())
.unwrap();
shadow_root
.upcast::<Node>()
.AppendChild(cx, slider_thumb.upcast::<Node>())
.unwrap();
slider_fill
.upcast::<Node>()
.set_implemented_pseudo_element(PseudoElement::SliderFill);
slider_thumb
.upcast::<Node>()
.set_implemented_pseudo_element(PseudoElement::SliderThumb);
slider_track
.upcast::<Node>()
.set_implemented_pseudo_element(PseudoElement::SliderTrack);
Self {
slider_fill: slider_fill.as_traced(),
slider_thumb: slider_thumb.as_traced(),
slider_track: slider_track.as_traced(),
}
}
pub(crate) fn update(&self, cx: &mut JSContext, input_element: &HTMLInputElement) {
let value = input_element.Value();
let min = input_element
.minimum()
.expect("This value should be available for range input.");
let max = input_element
.maximum()
.expect("This value should be available for range input.");
let value_num = input_element
.convert_string_to_number(&value.str())
.unwrap_or(input_element.default_range_value());
let percent = if min > max || (max - min).abs() < f64::EPSILON {
0.0
} else {
let clamped_value = value_num.clamp(min, max);
(clamped_value - min) / (max - min) * 100.0
};
self.slider_thumb.set_string_attribute(
&local_name!("style"),
format!("inset-inline-start: {percent}% !important;").into(),
CanGc::from_cx(cx),
);
self.slider_fill.set_string_attribute(
&local_name!("style"),
format!("width: {percent}% !important;").into(),
CanGc::from_cx(cx),
);
}
}

View File

@@ -0,0 +1,2 @@
[grid-self-alignment-stretch-input-range.html]
expected: FAIL

View File

@@ -1,2 +0,0 @@
[slider-fill-001.html]
expected: FAIL

View File

@@ -1,2 +0,0 @@
[slider-thumb-001.html]
expected: FAIL

View File

@@ -1,2 +0,0 @@
[slider-track-001.html]
expected: FAIL

View File

@@ -0,0 +1,2 @@
[range-percent-intrinsic-size-2.html]
expected: FAIL

View File

@@ -0,0 +1,2 @@
[range-percent-intrinsic-size-2a.html]
expected: FAIL

View File

@@ -155,5 +155,5 @@
[Trusted click on <input disabled="" type="week">, observed from <body>]
expected: FAIL
[Trusted click on <my-control disabled="">Text</my-control>, observed from <my-control>]
[Trusted click on <my-control disabled="">Text</my-control>, observed from <body>]
expected: FAIL

View File

@@ -0,0 +1,2 @@
[range-tick-marks-02.html]
expected: FAIL

View File

@@ -1,3 +0,0 @@
[range.html]
[range overflow styles]
expected: FAIL

View File

@@ -159,6 +159,35 @@
},
"reftest": {
"appearance": {
"input-range.html": [
"ed42641aa50cb89647ac3671da73d40b30f55f0d",
[
"appearance/input-range.html",
[
[
"/_mozilla/appearance/input-range-ref.html",
"=="
]
],
{
"fuzzy": [
[
null,
[
[
0,
6
],
[
0,
20
]
]
]
]
}
]
],
"input-text-caret-mixed-language.html": [
"d1f45de7548cca878fa90c6a0c67993f75ee5720",
[
@@ -8344,6 +8373,10 @@
[]
],
"appearance": {
"input-range-ref.html": [
"a805e58f2bf0f48b597a3fef54ac7fa7c50db3b5",
[]
],
"input-text-caret-mixed-language-ref.html": [
"ff0b91f2a8857fdefa4a8a3d7d75e06e53e699cf",
[]

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html>
<head>
<title>Input Range Elements Could be Shown Properly</title>
</head>
<body>
Display of the input range should match the display generated by the CSS reference for different values.
<style>
/* Minimal stylesheet to mimic the appearence of the input range specific to Servo.
* This stylesheet is expected to be modified following the development of the
* Shadow DOM input range in Servo.
*/
#input {
margin-inline: 8px;
height: 16px;
width: 100px;
display: inline-block;
position: relative;
background: none;
border: none;
}
#slider-track,
#slider-fill,
#slider-thumb {
/* All slider elements are centered vertically and use the default cursor. */
top: 50%;
transform: translateY(-50%);
position: absolute;
cursor: default;
}
#slider-track,
#slider-fill {
border-radius: 5px;
border-width: 1px;
border-style: solid;
}
#slider-track {
width: 100%;
height: 4px;
background-color: ButtonFace;
border-color: ButtonBorder;
}
#slider-fill {
height: 4px;
background-color: #007aff;
border-color: #007aff;
}
#slider-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #007aff;
/* Vertically centered, adjust inline direction by width so thumb
is centered on range value. */
transform: translate(-8px, -50%);
}
.definite-width {
}
</style>
<div>
<div id="input">
<div id="slider-track">
<div id="slider-fill" style="width: 0%;"></div>
</div>
<div id="slider-thumb" style="inset-inline-start: 0%;"></div>
</div>
</div>
<div>
<div id="input">
<div id="slider-track">
<div id="slider-fill" style="width: 50%;"></div>
</div>
<div id="slider-thumb" style="inset-inline-start: 50%;"></div>
</div>
</div>
<div>
<div id="input">
<div id="slider-track">
<div id="slider-fill" style="width: 100%;"></div>
</div>
<div id="slider-thumb" style="inset-inline-start: 100%;"></div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<title>Input Range Elements Could be Shown Properly</title>
<link rel="match" href="input-range-ref.html">
<meta name="fuzzy" content="maxDifference=0-6;totalPixels=0-20">
<link rel="help" href="https://github.com/servo/servo/pull/41562">
</head>
<body>
Display of the input range should match the display generated by the CSS reference for different values.
<div>
<input type="range" min="0" max="100" value="0"></input>
</div>
<div>
<input type="range" min="0" max="100" value="50"></input>
</div>
<div>
<input type="range" min="0" max="100" value="100"></input>
</div>
</body>
</html>