fix(forms): honor range input constraints

This commit is contained in:
ldm0
2026-09-27 19:22:01 +08:00
parent 8d64772cce
commit f1ad77ff11
9 changed files with 313 additions and 57 deletions
@@ -3086,8 +3086,6 @@ html/semantics/forms/form-submission-target/rel-input-target.html
html/semantics/forms/textfieldselection/selection.html
html/semantics/forms/the-button-element/button-click-submits-with-commandfor.html
html/semantics/forms/the-input-element/input-type-button.html
html/semantics/forms/the-input-element/range-2.html
html/semantics/forms/the-input-element/range.html
html/semantics/forms/the-input-element/show-picker-cross-origin-iframe.html
html/semantics/forms/the-select-element/customizable-select/base-appearance-inheritance.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-optgroup.html
@@ -6416,6 +6416,8 @@ html/semantics/forms/the-input-element/radio-input-cancel.html
html/semantics/forms/the-input-element/radio-morphed.html
html/semantics/forms/the-input-element/radio-multiple-selected.html
html/semantics/forms/the-input-element/radio.html
html/semantics/forms/the-input-element/range-2.html
html/semantics/forms/the-input-element/range.html
html/semantics/forms/the-input-element/required_attribute.html
html/semantics/forms/the-input-element/reset.html
html/semantics/forms/the-input-element/search_input.html
+165 -24
View File
@@ -1,4 +1,4 @@
use super::numeric::is_valid_number_input_value;
use super::numeric::{is_valid_number_input_value, number_aligns_to_step};
use moli_html_input_temporal::{
datetime_local_input_milliseconds, datetime_local_input_value_from_milliseconds,
is_valid_date_input_value, is_valid_month_input_value, is_valid_time_input_value,
@@ -33,10 +33,34 @@ pub fn sanitize_input_value_for_type(input_type: InputType, value: &str) -> Stri
sanitize_input_value_for_type_with_multiple(input_type, value, false)
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InputValueSanitizationContext<'a> {
pub multiple: bool,
pub min: Option<&'a str>,
pub max: Option<&'a str>,
pub step: Option<&'a str>,
pub value_attribute: Option<&'a str>,
}
pub fn sanitize_input_value_for_type_with_multiple(
input_type: InputType,
value: &str,
multiple: bool,
) -> String {
sanitize_input_value_for_type_with_context(
input_type,
value,
InputValueSanitizationContext {
multiple,
..InputValueSanitizationContext::default()
},
)
}
pub(crate) fn sanitize_input_value_for_type_with_context(
input_type: InputType,
value: &str,
context: InputValueSanitizationContext<'_>,
) -> String {
match input_type {
// Text-family — strip newlines / CR but leave whitespace runs alone.
@@ -48,7 +72,7 @@ pub fn sanitize_input_value_for_type_with_multiple(
let stripped = strip_input_value_line_breaks(value);
stripped.trim_matches(is_ascii_whitespace_char).to_owned()
}
InputType::Email => sanitize_email_input_value(value, multiple),
InputType::Email => sanitize_email_input_value(value, context.multiple),
InputType::Number if !is_valid_number_input_value(value) => String::new(),
InputType::Date if !is_valid_date_input_value(value) => String::new(),
InputType::Time if !is_valid_time_input_value(value) => String::new(),
@@ -57,11 +81,7 @@ pub fn sanitize_input_value_for_type_with_multiple(
.unwrap_or_default(),
InputType::Month if !is_valid_month_input_value(value) => String::new(),
InputType::Week if !is_valid_week_input_value(value) => String::new(),
// Range — sanitization: parse as float, clamp to [min, max], else
// default to "(min+max)/2". Without min/max context here we use the
// attribute-less default range 0..=100 (midpoint 50). Callers with
// attribute context can override.
InputType::Range => sanitize_range_value(value),
InputType::Range => sanitize_range_value(value, context),
// HTML-compatible color inputs accept CSS colors, discard alpha, and
// expose an opaque lowercase sRGB simple color.
InputType::Color => sanitize_color_value(value),
@@ -112,22 +132,87 @@ fn is_ascii_whitespace_char(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\n' | '\r' | '\x0C')
}
fn sanitize_range_value(value: &str) -> String {
// Default min=0, max=100 per spec — without attribute access here we
// can only honour that default; element-aware callers should override
// by computing the suggested default themselves.
match value.trim_matches(is_ascii_whitespace_char).parse::<f64>() {
Ok(parsed) if parsed.is_finite() => {
// Clamp to [0, 100]; preserve integer formatting when the result
// is an integer to match the HTML serializer for valid floats.
let clamped = parsed.clamp(0.0, 100.0);
if clamped == clamped.trunc() {
(clamped as i64).to_string()
} else {
clamped.to_string()
}
}
_ => "50".to_owned(),
fn sanitize_range_value(value: &str, context: InputValueSanitizationContext<'_>) -> String {
let minimum = context
.min
.and_then(parse_valid_range_number)
.unwrap_or(0.0);
let maximum = context
.max
.and_then(parse_valid_range_number)
.unwrap_or(100.0)
.max(minimum);
let step_base = context
.min
.and_then(parse_valid_range_number)
.or_else(|| context.value_attribute.and_then(parse_valid_range_number))
.unwrap_or(0.0);
let step = match context.step {
Some(step) if step.eq_ignore_ascii_case("any") => None,
Some(step) => Some(
parse_valid_range_number(step)
.filter(|step| *step > 0.0)
.unwrap_or(1.0),
),
None => Some(1.0),
};
let clamp = |value| clamp_range_value(value, minimum, maximum, step_base, step);
let default_value = clamp(minimum / 2.0 + maximum / 2.0);
let value = parse_valid_range_number(value).unwrap_or(default_value);
serialize_range_number(clamp(value))
}
fn parse_valid_range_number(value: &str) -> Option<f64> {
if value.is_empty() || !is_valid_number_input_value(value) {
return None;
}
value.parse::<f64>().ok().filter(|value| value.is_finite())
}
fn clamp_range_value(
value: f64,
minimum: f64,
maximum: f64,
step_base: f64,
step: Option<f64>,
) -> f64 {
let in_range = value.clamp(minimum, maximum);
let Some(step) = step else {
return in_range;
};
if number_aligns_to_step(in_range, step_base, step) {
return in_range;
}
let quotient = (in_range - step_base) / step;
if !quotient.is_finite() {
return in_range;
}
let lower = quotient.floor();
let rounded = if quotient - lower < 0.5 {
lower
} else {
lower + 1.0
};
let mut candidate = step_base + rounded * step;
if candidate > maximum {
candidate -= step;
} else if candidate < minimum {
candidate += step;
}
if candidate < minimum || candidate > maximum || !candidate.is_finite() {
in_range
} else {
candidate
}
}
fn serialize_range_number(value: f64) -> String {
if value == 0.0 {
"0".to_owned()
} else {
value.to_string()
}
}
@@ -226,7 +311,10 @@ pub fn url_value_type_mismatch(value: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{sanitize_input_value_for_type, sanitize_input_value_for_type_with_multiple};
use super::{
InputValueSanitizationContext, sanitize_input_value_for_type,
sanitize_input_value_for_type_with_context, sanitize_input_value_for_type_with_multiple,
};
use moli_html_input_type::InputType;
const INITIAL: &str = " foo\rbar ";
@@ -330,6 +418,59 @@ mod tests {
assert_eq!(sanitize_input_value_for_type(InputType::Range, "-5"), "0");
}
#[test]
fn range_uses_element_limits_and_step_alignment() {
let sanitize = |value, min, max, step, value_attribute| {
sanitize_input_value_for_type_with_context(
InputType::Range,
value,
InputValueSanitizationContext {
multiple: false,
min,
max,
step,
value_attribute,
},
)
};
assert_eq!(
sanitize("ppp", Some("0"), Some("5"), Some("xyz"), Some("ppp")),
"3"
);
assert_eq!(sanitize("7", Some("0"), Some("5"), None, Some("7")), "5");
assert_eq!(sanitize("", Some("2"), Some("6"), None, None), "4");
assert_eq!(sanitize("", Some("0"), Some("7"), Some("2"), None), "4");
assert_eq!(sanitize("", Some("2"), Some("-3"), None, None), "2");
assert_eq!(
sanitize("6.7", Some("5"), Some("12.6"), None, Some("6.7")),
"7"
);
assert_eq!(
sanitize("6.7", Some("5.3"), Some("12"), None, Some("6.7")),
"6.3"
);
assert_eq!(
sanitize("6.7", Some("5.3"), Some("12"), Some("0.5"), Some("6.7")),
"6.8"
);
assert_eq!(sanitize(" 123", None, None, None, Some(" 123")), "50");
assert_eq!(
sanitize("", Some("0"), Some("100"), Some("20"), Some("40")),
"60"
);
assert_eq!(
sanitize("0.6", Some("0"), Some("1"), Some("0.1"), Some("0.2")),
"0.6"
);
assert_eq!(sanitize("2.1", None, None, None, None), "2");
assert_eq!(sanitize("2.1", None, None, Some("any"), None), "2.1");
assert_eq!(
sanitize("-2.5", Some("-10"), Some("10"), Some("1"), None),
"-2"
);
}
#[test]
fn color_parses_css_colors_and_defaults_to_black_for_invalid_input() {
assert_eq!(
+3
View File
@@ -3,6 +3,9 @@ mod numeric;
mod text;
mod validity;
pub(crate) use input_type::{
InputValueSanitizationContext, sanitize_input_value_for_type_with_context,
};
pub use input_type::{
email_value_type_mismatch, form_control_type_supports_intrinsic_validation,
input_type_has_value_sanitization, input_type_value_mismatch, is_valid_email_address,
+26 -12
View File
@@ -1,5 +1,5 @@
use super::Attribute;
use crate::forms::sanitize_input_value_for_type_with_multiple;
use crate::forms::{InputValueSanitizationContext, sanitize_input_value_for_type_with_context};
use crate::native::NativeNodeId;
use indexmap::IndexSet;
use moli_html_input_type::InputType;
@@ -246,10 +246,16 @@ impl ElementControlState {
if local_name == "input" {
let input_type = InputType::from_attribute_value(attribute("type"));
state.input_value = Some(sanitize_input_value_for_type_with_multiple(
state.input_value = Some(sanitize_input_value_for_type_with_context(
input_type,
attribute("value").unwrap_or_default(),
attribute("multiple").is_some(),
InputValueSanitizationContext {
multiple: attribute("multiple").is_some(),
min: attribute("min"),
max: attribute("max"),
step: attribute("step"),
value_attribute: attribute("value"),
},
));
state.checked = Some(attribute("checked").is_some());
state.selection_start = Some(0);
@@ -875,8 +881,7 @@ impl ElementControlState {
namespace: &str,
local_name: &str,
input_type: InputType,
input_value_attribute: Option<&str>,
input_multiple: bool,
input_context: InputValueSanitizationContext<'_>,
attribute_name: &str,
attribute_value: Option<&str>,
) {
@@ -889,10 +894,10 @@ impl ElementControlState {
match (local_name, attribute_name) {
("input", "value") => {
if !self.input_value_dirty {
self.input_value = Some(sanitize_input_value_for_type_with_multiple(
self.input_value = Some(sanitize_input_value_for_type_with_context(
input_type,
attribute_value.unwrap_or_default(),
input_multiple,
input_context,
));
}
}
@@ -905,10 +910,10 @@ impl ElementControlState {
let source = if self.input_value_dirty {
self.input_value.as_deref().unwrap_or_default()
} else {
input_value_attribute.unwrap_or_default()
input_context.value_attribute.unwrap_or_default()
};
let value =
sanitize_input_value_for_type_with_multiple(input_type, source, input_multiple);
sanitize_input_value_for_type_with_context(input_type, source, input_context);
self.input_value = Some(value);
self.input_bad_input = false;
@@ -927,12 +932,21 @@ impl ElementControlState {
let source = if self.input_value_dirty {
self.input_value.as_deref().unwrap_or_default()
} else {
input_value_attribute.unwrap_or_default()
input_context.value_attribute.unwrap_or_default()
};
self.input_value = Some(sanitize_input_value_for_type_with_multiple(
self.input_value = Some(sanitize_input_value_for_type_with_context(
input_type,
source,
input_multiple,
input_context,
));
self.input_bad_input = false;
}
("input", "min" | "max" | "step") if input_type == InputType::Range => {
let source = self.input_value.as_deref().unwrap_or_default();
self.input_value = Some(sanitize_input_value_for_type_with_context(
input_type,
source,
input_context,
));
self.input_bad_input = false;
}
+39 -12
View File
@@ -22,7 +22,8 @@ use super::NativeDom;
use super::node::{NativeNodeId, Node};
use crate::custom_elements::is_valid_custom_element_name;
use crate::forms::{
InputType, is_valid_number_input_value, sanitize_input_value_for_type_with_multiple,
InputType, InputValueSanitizationContext, is_valid_number_input_value,
sanitize_input_value_for_type_with_context,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -594,13 +595,29 @@ impl Element {
}
fn sanitized_input_value(&self, value: &str) -> String {
sanitize_input_value_for_type_with_multiple(
sanitize_input_value_for_type_with_context(
self.input_type(),
value,
self.has_attribute("multiple"),
InputValueSanitizationContext {
multiple: self.has_attribute("multiple"),
min: self.attribute("min"),
max: self.attribute("max"),
step: self.attribute("step"),
value_attribute: self.attribute("value"),
},
)
}
pub(crate) fn resanitize_input_value_after_parser_attributes(&mut self) -> bool {
if !self.is_html_input() || self.input_value_dirty() {
return false;
}
let source = self.attribute("value").unwrap_or_default().to_owned();
let value = self.sanitized_input_value(&source);
self.control_state_mut()
.set_input_value_with_dirty(&value, false)
}
pub fn set_input_value(&mut self, value: &str) -> bool {
if !self.is_html_input() && !self.is_html_textarea() {
return false;
@@ -1186,24 +1203,34 @@ impl Element {
) {
let is_html_input =
self.namespace() == "http://www.w3.org/1999/xhtml" && self.local_name() == "input";
let input_value_attribute =
if is_html_input && matches!(attribute_name, "type" | "multiple") {
self.attribute("value").map(str::to_owned)
} else {
None
};
let input_value_attribute = is_html_input
.then(|| self.attribute("value").map(str::to_owned))
.flatten();
let input_min = is_html_input
.then(|| self.attribute("min").map(str::to_owned))
.flatten();
let input_max = is_html_input
.then(|| self.attribute("max").map(str::to_owned))
.flatten();
let input_step = is_html_input
.then(|| self.attribute("step").map(str::to_owned))
.flatten();
let input_type = if is_html_input && attribute_name == "type" {
InputType::from_attribute_value(attribute_value)
} else {
self.input_type()
};
let input_multiple = is_html_input && self.has_attribute("multiple");
self.rare_data.sync_control_state_from_attribute(
self.namespace.as_ref(),
self.local_name.as_ref(),
input_type,
input_value_attribute.as_deref(),
input_multiple,
InputValueSanitizationContext {
multiple: is_html_input && self.has_attribute("multiple"),
min: input_min.as_deref(),
max: input_max.as_deref(),
step: input_step.as_deref(),
value_attribute: input_value_attribute.as_deref(),
},
attribute_name,
attribute_value,
);
+4 -7
View File
@@ -1,7 +1,7 @@
use std::sync::LazyLock;
use super::{Attribute, CustomElementState, ElementControlState};
use crate::forms::InputType;
use crate::forms::{InputType, InputValueSanitizationContext};
use crate::native::NativeNodeId;
static EMPTY_CONTROL_STATE: LazyLock<ElementControlState> =
@@ -215,8 +215,7 @@ impl ElementRareData {
namespace: &str,
local_name: &str,
input_type: InputType,
input_value_attribute: Option<&str>,
input_multiple: bool,
input_context: InputValueSanitizationContext<'_>,
attribute_name: &str,
attribute_value: Option<&str>,
) {
@@ -229,8 +228,7 @@ impl ElementRareData {
namespace,
local_name,
input_type,
input_value_attribute,
input_multiple,
input_context,
attribute_name,
attribute_value,
);
@@ -250,8 +248,7 @@ impl ElementRareData {
namespace,
local_name,
input_type,
input_value_attribute,
input_multiple,
input_context,
attribute_name,
attribute_value,
);
+42
View File
@@ -366,6 +366,48 @@ fn input_type_change_sanitizes_without_dirtying_default_value() {
assert!(input.input_value_dirty());
}
#[test]
fn range_input_value_uses_live_min_max_and_step_attributes() {
let attribute = |name: &str, value: &str| {
Attribute::new(name.to_owned(), String::new(), None, value.to_owned())
};
let mut input = Element::new(
"input".to_owned(),
"http://www.w3.org/1999/xhtml".to_owned(),
None,
vec![
attribute("type", "range"),
attribute("min", "0"),
attribute("max", "100"),
attribute("step", "20"),
attribute("value", "40"),
],
);
assert_eq!(input.input_value(), "40");
assert!(input.set_input_value(""));
assert_eq!(input.input_value(), "60");
assert!(input.input_value_dirty());
assert!(input.set_input_value("80"));
assert!(input.set_attribute("max".to_owned(), String::new(), None, "50".to_owned()));
assert_eq!(input.input_value(), "40");
assert!(input.input_value_dirty());
let defaulted = Element::new(
"input".to_owned(),
"http://www.w3.org/1999/xhtml".to_owned(),
None,
vec![
attribute("type", "range"),
attribute("min", "2"),
attribute("max", "6"),
],
);
assert_eq!(defaulted.input_value(), "4");
assert!(!defaulted.input_value_dirty());
}
#[test]
fn heading_reflections_parse_range_and_modal_state() {
+32
View File
@@ -216,6 +216,7 @@ impl DomHost {
changed |= attribute_changed;
}
}
changed |= element.resanitize_input_value_after_parser_attributes();
changed |= element.mark_undefined_custom_element_candidate_from_identity();
if changed {
@@ -273,6 +274,37 @@ mod tests {
assert_eq!(host.get_attribute(element, "id").as_deref(), Some("card"));
}
#[test]
fn parser_input_value_sanitization_observes_the_complete_token_attribute_set() {
let mut host = test_host();
let input = host.create_parser_element_without_attributes(
"input".to_owned(),
"http://www.w3.org/1999/xhtml".to_owned(),
None,
);
let attribute = |name: &str, value: &str| {
Attribute::new(name.to_owned(), String::new(), None, value.to_owned())
};
host.add_attrs_if_missing_for_parser(
input,
vec![
attribute("type", "range"),
attribute("id", "range"),
attribute("min", "2"),
attribute("max", "6"),
],
);
assert_eq!(
host.node(input)
.and_then(Node::as_element)
.map(Element::input_value)
.as_deref(),
Some("4")
);
}
#[test]
fn parser_script_prepare_state_survives_parser_attribute_attach() {
let mut host = test_host();