diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index fc0a904d48..9c040b946a 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -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 diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index f0dba38532..7573c9977a 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -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 diff --git a/moli-dom/src/forms/input_type.rs b/moli-dom/src/forms/input_type.rs index 58b6f1b66a..b46723123d 100644 --- a/moli-dom/src/forms/input_type.rs +++ b/moli-dom/src/forms/input_type.rs @@ -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::() { - 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 { + if value.is_empty() || !is_valid_number_input_value(value) { + return None; + } + value.parse::().ok().filter(|value| value.is_finite()) +} + +fn clamp_range_value( + value: f64, + minimum: f64, + maximum: f64, + step_base: f64, + step: Option, +) -> 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!( diff --git a/moli-dom/src/forms/mod.rs b/moli-dom/src/forms/mod.rs index 986ff8be05..784ef2e43d 100644 --- a/moli-dom/src/forms/mod.rs +++ b/moli-dom/src/forms/mod.rs @@ -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, diff --git a/moli-dom/src/native/element/control_state.rs b/moli-dom/src/native/element/control_state.rs index 4620c323b3..ee7b2e6a3b 100644 --- a/moli-dom/src/native/element/control_state.rs +++ b/moli-dom/src/native/element/control_state.rs @@ -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; } diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index 21a9d1b178..48ecea1fc7 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -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, ); diff --git a/moli-dom/src/native/element/rare_data.rs b/moli-dom/src/native/element/rare_data.rs index 6ce7223ec5..89cf53440e 100644 --- a/moli-dom/src/native/element/rare_data.rs +++ b/moli-dom/src/native/element/rare_data.rs @@ -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 = @@ -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, ); diff --git a/moli-dom/src/native/element/tests.rs b/moli-dom/src/native/element/tests.rs index 395cfe5c1c..115a0599c7 100644 --- a/moli-dom/src/native/element/tests.rs +++ b/moli-dom/src/native/element/tests.rs @@ -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() { diff --git a/moli-dom/src/native/host/parser.rs b/moli-dom/src/native/host/parser.rs index 92560a7116..977fec179e 100644 --- a/moli-dom/src/native/host/parser.rs +++ b/moli-dom/src/native/host/parser.rs @@ -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();