From 1149a1941145d0c071dcc9c8668e73a2ef3b23f0 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 28 Apr 2026 02:23:40 +0200 Subject: [PATCH 1/2] forms: round value to nearest step on the step ladder Per WHATWG HTML "Suffering from a step mismatch", value sanitization for rounds the value to the nearest valid `step base + step * n` after the existing min/max clamp. Ties round toward positive infinity; if the rounded-up neighbor exceeds max, the rounded-down neighbor is used instead. `step` defaults to 1; `step="any"` (case-insensitive) disables step matching. When the post-snap value matches the parsed input value exactly, the original string is returned unchanged so assignments like `el.value = "1.0"` round-trip without canonicalization. Closes #2277 --- .../tests/element/html/input-attrs.html | 106 +++++++++++++++++- src/browser/webapi/element/html/Input.zig | 61 ++++++++-- 2 files changed, 155 insertions(+), 12 deletions(-) diff --git a/src/browser/tests/element/html/input-attrs.html b/src/browser/tests/element/html/input-attrs.html index 41f0e75a..63ec17a7 100644 --- a/src/browser/tests/element/html/input-attrs.html +++ b/src/browser/tests/element/html/input-attrs.html @@ -129,10 +129,6 @@ testing.expectEqual('1.5', r3.value); r3.value = '0'; testing.expectEqual('0.5', r3.value); - // Note: in-range pass-through under clamping alone is exercised by r1/r4. - // An assertion like `r3.value = '1' -> '1'` would conflict with WHATWG step - // matching (default step=1, base=min=0.5 -> nearest valid is '1.5'), which - // is intentionally out of scope for this PR; tracking separately. // Default min/max (0..100) when attributes absent const r4 = document.createElement('input'); @@ -159,3 +155,105 @@ testing.expectEqual('30', r6.value); } + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index d6175e8c..e8ac31a0 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -564,7 +564,7 @@ fn sanitizeValue(self: *Input, comptime dupe: bool, value: []const u8, frame: *F .time => return if (isValidTime(value)) if (comptime dupe) try frame.dupeString(value) else value else "", .@"datetime-local" => return try sanitizeDatetimeLocal(dupe, value, frame.arena), .number => return if (isValidFloatingPoint(value)) if (comptime dupe) try frame.dupeString(value) else value else "", - .range => return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), frame), + .range => return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), self.getStep(), frame), .color => { if (value.len == 7 and value[0] == '#') { var needs_lower = false; @@ -792,14 +792,19 @@ fn sanitizeDatetimeLocal(comptime dupe: bool, value: []const u8, arena: std.mem. /// `min + (max - min) / 2`. /// 2. If value < min, set it to min. /// 3. If value > max, set it to max. +/// 4. If value is not on the step ladder (`step base + step * n` for integer +/// `n`), round to nearest valid value, ties up. The rounded value must +/// stay in `[min, max]`; if rounding up exceeds max, use the rounded-down +/// neighbor instead. /// `min`/`max` default to 0 and 100 respectively when the attribute is missing -/// or fails to parse as a valid floating-point number. Step matching is not -/// applied here. +/// or fails to parse as a valid floating-point number. `step` defaults to 1; +/// `step="any"` (case-insensitive) disables step matching. fn sanitizeRange( comptime dupe: bool, value: []const u8, min_attr: []const u8, max_attr: []const u8, + step_attr: []const u8, frame: *Frame, ) ![]const u8 { const min: f64 = if (isValidFloatingPoint(min_attr)) @@ -812,13 +817,53 @@ fn sanitizeRange( 100; if (!isValidFloatingPoint(value)) { - return try formatFloat(frame.arena, min + (max - min) / 2); + return try formatFloat(frame.arena, snapToStep(min + (max - min) / 2, min, max, min, step_attr)); } - const v = std.fmt.parseFloat(f64, value) catch unreachable; // grammar already validated - if (v < min) return try formatFloat(frame.arena, min); - if (v > max) return try formatFloat(frame.arena, max); - return if (comptime dupe) try frame.dupeString(value) else value; + const v0 = std.fmt.parseFloat(f64, value) catch unreachable; // grammar already validated + var v = v0; + if (v < min) v = min; + if (v > max) v = max; + const snapped = snapToStep(v, min, max, min, step_attr); + if (v == v0 and snapped == v) { + // Already valid and on the ladder — preserve the original string so + // assignments like `el.value = "1.0"` round-trip without canonicalizing. + return if (comptime dupe) try frame.dupeString(value) else value; + } + return try formatFloat(frame.arena, snapped); +} + +/// Snap `value` (already clamped to `[min, max]`) to the nearest value on the +/// step ladder `step_base + step * n`. Ties round up; if the rounded-up +/// neighbor exceeds `max`, use the rounded-down neighbor. Returns `value` +/// unchanged for `step="any"` (case-insensitive) or when no ladder rung lands +/// in `[min, max]`. +fn snapToStep(value: f64, min: f64, max: f64, step_base: f64, step_attr: []const u8) f64 { + if (step_attr.len == 3 and std.ascii.eqlIgnoreCase(step_attr, "any")) return value; + + const step: f64 = blk: { + if (isValidFloatingPoint(step_attr)) { + const s = std.fmt.parseFloat(f64, step_attr) catch break :blk 1; + if (s > 0) break :blk s; + } + break :blk 1; + }; + + const diff = (value - step_base) / step; + const n_floor = @floor(diff); + const n_ceil = @ceil(diff); + const n: f64 = if (n_floor == n_ceil) n_floor else blk: { + const dist_floor = diff - n_floor; + const dist_ceil = n_ceil - diff; + if (dist_ceil < dist_floor) break :blk n_ceil; + if (dist_floor < dist_ceil) break :blk n_floor; + break :blk n_ceil; // tie -> round up + }; + + var candidate = step_base + n * step; + if (candidate > max) candidate = step_base + (n - 1) * step; + if (candidate < min) return value; // no valid rung in range; leave clamped value + return candidate; } /// Format an f64 to its shortest decimal representation, arena-allocated. From 577a405f61ee48add7cf6904dbd2a26278c6aeff Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 28 Apr 2026 04:19:06 +0200 Subject: [PATCH 2/2] forms: address review feedback on range step matching - Drop redundant `step_attr.len == 3` guard in `snapToStep`. Zig's `std.ascii.eqlIgnoreCase` already short-circuits on length mismatch (`ascii.zig:329`), so the outer length check is wasted work. - Implement the `value` content attribute fallback for step base. Per https://html.spec.whatwg.org/multipage/input.html#concept-input-min the chain is `min` content attr -> `value` content attr -> 0. The fallback was previously skipped (step base went straight from `min` to 0), which meant `` then `el.value = '5.3'` snapped to `5` instead of the spec-required `5.5`. Threads `value_attr` through `sanitizeRange` so the fallback engages whenever `min` is absent or unparseable but a parseable `value` content attr exists. Three new test cases cover the fallback path (s10), the unparseable `value` attr that falls through to 0 (s11), and `min` taking precedence over `value` attr (s12). --- .../tests/element/html/input-attrs.html | 30 +++++++++++++++++++ src/browser/webapi/element/html/Input.zig | 22 ++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/browser/tests/element/html/input-attrs.html b/src/browser/tests/element/html/input-attrs.html index 63ec17a7..89372766 100644 --- a/src/browser/tests/element/html/input-attrs.html +++ b/src/browser/tests/element/html/input-attrs.html @@ -255,5 +255,35 @@ s9.step = '7'; // ladder: 0, 7, 14, ..., 98; midpoint=50; nearest is 49 s9.value = 'garbage'; testing.expectEqual('49', s9.value); + + // Step base falls back through `min` content attr -> `value` content attr -> 0 + // (https://html.spec.whatwg.org/multipage/input.html#concept-input-min). + // When `min` is absent but a parseable `value` content attribute exists, + // it becomes the step base — so the ladder shifts. + const s10 = document.createElement('input'); + s10.type = 'range'; + s10.max = '10'; + s10.setAttribute('value', '3.5'); // content attr only; step_base = 3.5 + s10.value = '5.3'; // ladder [3.5, 4.5, 5.5, ...]; nearest is 5.5 + testing.expectEqual('5.5', s10.value); + + // Unparseable `value` content attr fails the fallback chain and step base + // defaults to 0 (ladder [0, 1, 2, ...]; nearest to 5.3 is 5). + const s11 = document.createElement('input'); + s11.type = 'range'; + s11.max = '10'; + s11.setAttribute('value', 'garbage'); + s11.value = '5.3'; + testing.expectEqual('5', s11.value); + + // `min` content attr wins over `value` content attr in the fallback chain + // (step_base = 0 from min, not 3.5 from value attr). + const s12 = document.createElement('input'); + s12.type = 'range'; + s12.min = '0'; + s12.max = '10'; + s12.setAttribute('value', '3.5'); + s12.value = '5.3'; + testing.expectEqual('5', s12.value); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index e8ac31a0..f5fe00d5 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -564,7 +564,10 @@ fn sanitizeValue(self: *Input, comptime dupe: bool, value: []const u8, frame: *F .time => return if (isValidTime(value)) if (comptime dupe) try frame.dupeString(value) else value else "", .@"datetime-local" => return try sanitizeDatetimeLocal(dupe, value, frame.arena), .number => return if (isValidFloatingPoint(value)) if (comptime dupe) try frame.dupeString(value) else value else "", - .range => return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), self.getStep(), frame), + .range => { + const value_attr = self.asConstElement().getAttributeSafe(comptime .wrap("value")) orelse ""; + return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), self.getStep(), value_attr, frame); + }, .color => { if (value.len == 7 and value[0] == '#') { var needs_lower = false; @@ -798,13 +801,16 @@ fn sanitizeDatetimeLocal(comptime dupe: bool, value: []const u8, arena: std.mem. /// neighbor instead. /// `min`/`max` default to 0 and 100 respectively when the attribute is missing /// or fails to parse as a valid floating-point number. `step` defaults to 1; -/// `step="any"` (case-insensitive) disables step matching. +/// `step="any"` (case-insensitive) disables step matching. The step base +/// (https://html.spec.whatwg.org/multipage/input.html#concept-input-min) falls +/// back through `min` content attr → `value` content attr → 0. fn sanitizeRange( comptime dupe: bool, value: []const u8, min_attr: []const u8, max_attr: []const u8, step_attr: []const u8, + value_attr: []const u8, frame: *Frame, ) ![]const u8 { const min: f64 = if (isValidFloatingPoint(min_attr)) @@ -815,16 +821,22 @@ fn sanitizeRange( std.fmt.parseFloat(f64, max_attr) catch 100 else 100; + const step_base: f64 = if (isValidFloatingPoint(min_attr)) + std.fmt.parseFloat(f64, min_attr) catch 0 + else if (isValidFloatingPoint(value_attr)) + std.fmt.parseFloat(f64, value_attr) catch 0 + else + 0; if (!isValidFloatingPoint(value)) { - return try formatFloat(frame.arena, snapToStep(min + (max - min) / 2, min, max, min, step_attr)); + return try formatFloat(frame.arena, snapToStep(min + (max - min) / 2, min, max, step_base, step_attr)); } const v0 = std.fmt.parseFloat(f64, value) catch unreachable; // grammar already validated var v = v0; if (v < min) v = min; if (v > max) v = max; - const snapped = snapToStep(v, min, max, min, step_attr); + const snapped = snapToStep(v, min, max, step_base, step_attr); if (v == v0 and snapped == v) { // Already valid and on the ladder — preserve the original string so // assignments like `el.value = "1.0"` round-trip without canonicalizing. @@ -839,7 +851,7 @@ fn sanitizeRange( /// unchanged for `step="any"` (case-insensitive) or when no ladder rung lands /// in `[min, max]`. fn snapToStep(value: f64, min: f64, max: f64, step_base: f64, step_attr: []const u8) f64 { - if (step_attr.len == 3 and std.ascii.eqlIgnoreCase(step_attr, "any")) return value; + if (std.ascii.eqlIgnoreCase(step_attr, "any")) return value; const step: f64 = blk: { if (isValidFloatingPoint(step_attr)) {