Merge pull request #2280 from navidemad/fix-2277-input-range-step-matching

forms: round <input type=range> value to nearest step on the step ladder
This commit is contained in:
Karl Seguin
2026-04-28 10:55:48 +08:00
committed by GitHub
2 changed files with 197 additions and 12 deletions

View File

@@ -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,135 @@
testing.expectEqual('30', r6.value);
}
</script>
<script id="range-step-matching">
{
// Per WHATWG HTML "Suffering from a step mismatch":
// step base = min (defaults to 0)
// step value = step attr (defaults to 1)
// value rounds to nearest `step base + step * n`; ties round up
// if rounded-up neighbor exceeds max, use rounded-down neighbor
// Default step=1, fractional min -> ladder [0.5, 1.5]; '1' is a tie, rounds up
const s1 = document.createElement('input');
s1.type = 'range';
s1.min = '0.5';
s1.max = '1.5';
s1.value = '1';
testing.expectEqual('1.5', s1.value);
// step=2, ladder [0,2,4,6,8,10]; '3' is a tie between 2 and 4, rounds up to 4
const s2 = document.createElement('input');
s2.type = 'range';
s2.min = '0';
s2.max = '10';
s2.step = '2';
s2.value = '3';
testing.expectEqual('4', s2.value);
s2.value = '7'; // tie between 6 and 8 -> 8
testing.expectEqual('8', s2.value);
s2.value = '5.4'; // closer to 6
testing.expectEqual('6', s2.value);
// step="any" -> no rounding
const s3 = document.createElement('input');
s3.type = 'range';
s3.min = '0';
s3.max = '10';
s3.step = 'any';
s3.value = '3.7';
testing.expectEqual('3.7', s3.value);
s3.step = 'ANY'; // case-insensitive
s3.value = '3.7';
testing.expectEqual('3.7', s3.value);
// step=3, ladder [0,3,6,9]; clamp '11' to 10, then nearest rung is 9
const s4 = document.createElement('input');
s4.type = 'range';
s4.min = '0';
s4.max = '10';
s4.step = '3';
s4.value = '11';
testing.expectEqual('9', s4.value);
// step=4, ladder [0,4,8,12]; '10' is a tie between 8 and 12, but 12 > max=10
// so use the rounded-down neighbor 8
const s5 = document.createElement('input');
s5.type = 'range';
s5.min = '0';
s5.max = '10';
s5.step = '4';
s5.value = '10';
testing.expectEqual('8', s5.value);
// Already on the ladder -> preserve original string (no canonicalization)
const s6 = document.createElement('input');
s6.type = 'range';
s6.min = '0';
s6.max = '10';
s6.value = '4';
testing.expectEqual('4', s6.value);
// Invalid step (<= 0) falls back to default step=1
const s7 = document.createElement('input');
s7.type = 'range';
s7.min = '0';
s7.max = '10';
s7.step = '0';
s7.value = '3.4';
testing.expectEqual('3', s7.value);
s7.step = '-2';
s7.value = '3.4';
testing.expectEqual('3', s7.value);
// Negative range with step matching
const s8 = document.createElement('input');
s8.type = 'range';
s8.min = '-10';
s8.max = '10';
s8.step = '5'; // ladder: -10, -5, 0, 5, 10
s8.value = '-3'; // tie between -5 and 0 (each 2 away)? no: |-3-(-5)|=2, |0-(-3)|=3 -> -5
testing.expectEqual('-5', s8.value);
s8.value = '-2.5'; // tie between -5 and 0 -> rounds up to 0
testing.expectEqual('0', s8.value);
// Non-numeric value falls back to midpoint, then snaps to ladder
const s9 = document.createElement('input');
s9.type = 'range';
s9.min = '0';
s9.max = '100';
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);
}
</script>

View File

@@ -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(), 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;
@@ -792,14 +795,22 @@ 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. 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))
@@ -810,15 +821,61 @@ 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, min + (max - min) / 2);
return try formatFloat(frame.arena, snapToStep(min + (max - min) / 2, min, max, step_base, 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, 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.
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 (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.