From 5e047950e2792fc295dfd5ac70f7af0d7b3ab99e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 4 Oct 2026 05:27:43 +0800 Subject: [PATCH] fix(forms): round temporal input steps to millisecond precision --- moli-dom/src/forms/numeric.rs | 22 +++++++- moli-dom/src/forms/tests.rs | 50 +++++++++++++++++++ .../src/script_vm/tests/dom_xhr/forms.rs | 39 +++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/moli-dom/src/forms/numeric.rs b/moli-dom/src/forms/numeric.rs index 7cfaff0bf8..fefafde9c8 100644 --- a/moli-dom/src/forms/numeric.rs +++ b/moli-dom/src/forms/numeric.rs @@ -209,11 +209,31 @@ pub fn input_step(input_type: InputType, step: Option<&str>) -> Option { Some( parse_finite_number(step) .filter(|value| *value > 0.0) - .map(|value| value * input_step_scale(input_type)) + .map(|value| { + let scaled = value * input_step_scale(input_type); + if matches!(input_type, InputType::Time | InputType::DatetimeLocal) { + temporal_step_milliseconds(step).unwrap_or_else(|| scaled.round().max(1.0)) + } else { + scaled + } + }) .unwrap_or_else(|| default_input_step(input_type)), ) } +fn temporal_step_milliseconds(step: &str) -> Option { + let value = parse_decimal_number(step)?; + let milliseconds = if value.scale <= 3 { + value + .coefficient + .checked_mul(checked_pow10(3 - value.scale)?)? + } else { + let divisor = checked_pow10(value.scale - 3)?; + value.coefficient / divisor + i128::from(value.coefficient % divisor >= divisor / 2) + }; + Some(milliseconds.max(1) as f64) +} + fn default_input_step(input_type: InputType) -> f64 { match input_type { InputType::Date => MS_PER_DAY, diff --git a/moli-dom/src/forms/tests.rs b/moli-dom/src/forms/tests.rs index ee058a9af7..d510b3164d 100644 --- a/moli-dom/src/forms/tests.rs +++ b/moli-dom/src/forms/tests.rs @@ -439,6 +439,56 @@ fn meter_values_share_html_numeric_clamping_and_gauge_region_rules() { ); } +#[test] +fn temporal_steps_round_to_millisecond_precision_before_stepping() { + for kind in [InputType::Time, InputType::DatetimeLocal] { + for (step, expected) in [ + ("0.9999", 1000.0), + ("1.0025", 1003.0), + ("2.0004", 2000.0), + ("2.0005", 2001.0), + ("0.0001", 1.0), + ("5e-4", 1.0), + ("59.9999", 60000.0), + ] { + assert_eq!( + input_step(kind, Some(step)), + Some(expected), + "{kind:?} step={step}" + ); + } + assert_eq!(input_step(kind, Some("any")), None); + assert_eq!(input_step(kind, Some("0")), Some(60000.0)); + } + assert_eq!(input_step(InputType::Number, Some("0.0001")), Some(0.0001)); + assert_eq!(input_step(InputType::Range, Some("0.9999")), Some(0.9999)); + for (kind, initial, expected) in [ + (InputType::Time, "00:00:01.003", "00:00:02.006"), + ( + InputType::DatetimeLocal, + "1970-01-01T00:00:01.003", + "1970-01-01T00:00:02.006", + ), + ] { + assert_eq!( + step_input_value( + InputStepState { + input_type: kind, + value: initial, + min: None, + max: None, + step: Some("1.0025"), + value_attribute: None, + }, + InputStepDirection::Up, + 1.0 + ) + .unwrap(), + InputStepOutcome::Set(expected.into()) + ); + } +} + #[test] fn input_step_up_down_rules_are_shared() { let stepped = step_input_value( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 67f48a5838..4b50757490 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -6008,3 +6008,42 @@ fn disconnected_radio_groups_follow_tree_roots_and_form_owners() { "true|true:false|true|true:false|true|true|true:true|false:true" ); } + +#[test] +fn temporal_input_validation_and_stepping_use_rounded_millisecond_steps() { + for (kind, step, initial, after_up) in [ + ("time", "0.9999", "12:30:02", "12:30:03"), + ("time", "1.0025", "00:00:01.003", "00:00:02.006"), + ("time", "0.0001", "00:00:00.001", "00:00:00.002"), + ( + "datetime-local", + "59.9999", + "2024-02-29T12:30", + "2024-02-29T12:31", + ), + ( + "datetime-local", + "1.0025", + "1970-01-01T00:00:01.003", + "1970-01-01T00:00:02.006", + ), + ] { + let mut vm = new_storage_test_vm("https://temporal-step.test/"); + let result=vm.eval(&format!(r#" + (() => {{ + const input=document.createElement('input'); + input.type={kind:?};input.step={step:?};input.value={initial:?}; + const initialMismatch=input.validity.stepMismatch; + input.stepUp(); + const afterUp=input.value, afterUpMismatch=input.validity.stepMismatch; + input.stepDown(); + return JSON.stringify([initialMismatch,afterUp,afterUpMismatch,input.value,input.validity.stepMismatch]); + }})() + "#)).expect("ordinary temporal input probe"); + assert_eq!( + result, + format!("[false,{after_up:?},false,{initial:?},false]"), + "{kind} step={step}" + ); + } +}