Merge pull request #2267 from navidemad/fix-a17-input-range-clamp

forms: clamp <input type=range> value to min/max
This commit is contained in:
Karl Seguin
2026-04-28 07:27:31 +08:00
committed by GitHub
2 changed files with 115 additions and 1 deletions

View File

@@ -85,3 +85,77 @@
testing.expectEqual('', i3.autocomplete);
}
</script>
<script id="range-clamp">
{
// Per WHATWG HTML spec, value sanitization for `type=range` clamps to
// [min, max] with defaults of 0 and 100.
// https://html.spec.whatwg.org/multipage/input.html#range-state-(type=range)
const r = document.createElement('input');
r.type = 'range';
r.min = '0';
r.max = '100';
// Above max -> max
r.value = '999';
testing.expectEqual('100', r.value);
// Below min -> min
r.value = '-10';
testing.expectEqual('0', r.value);
// In range -> unchanged
r.value = '50';
testing.expectEqual('50', r.value);
// Negative range
const r2 = document.createElement('input');
r2.type = 'range';
r2.min = '-50';
r2.max = '50';
r2.value = '0';
testing.expectEqual('0', r2.value);
r2.value = '100';
testing.expectEqual('50', r2.value);
r2.value = '-100';
testing.expectEqual('-50', r2.value);
// Fractional bounds
const r3 = document.createElement('input');
r3.type = 'range';
r3.min = '0.5';
r3.max = '1.5';
r3.value = '2';
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');
r4.type = 'range';
r4.value = '999';
testing.expectEqual('100', r4.value);
r4.value = '-1';
testing.expectEqual('0', r4.value);
// Non-numeric value falls back to spec default `min + (max - min) / 2`
const r5 = document.createElement('input');
r5.type = 'range';
r5.min = '0';
r5.max = '100';
r5.value = 'garbage';
testing.expectEqual('50', r5.value);
// Spec default is the midpoint, not a hardcoded "50"
const r6 = document.createElement('input');
r6.type = 'range';
r6.min = '20';
r6.max = '40';
r6.value = 'garbage';
testing.expectEqual('30', r6.value);
}
</script>

View File

@@ -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 if (isValidFloatingPoint(value)) if (comptime dupe) try frame.dupeString(value) else value else "50",
.range => return try sanitizeRange(dupe, value, self.getMin(), self.getMax(), frame),
.color => {
if (value.len == 7 and value[0] == '#') {
var needs_lower = false;
@@ -786,6 +786,46 @@ fn sanitizeDatetimeLocal(comptime dupe: bool, value: []const u8, arena: std.mem.
return result[0..total_len];
}
/// Sanitize value for `<input type=range>` per WHATWG HTML spec:
/// https://html.spec.whatwg.org/multipage/input.html#range-state-(type=range)
/// 1. If value is not a valid floating-point number, set it to
/// `min + (max - min) / 2`.
/// 2. If value < min, set it to min.
/// 3. If value > max, set it to max.
/// `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.
fn sanitizeRange(
comptime dupe: bool,
value: []const u8,
min_attr: []const u8,
max_attr: []const u8,
frame: *Frame,
) ![]const u8 {
const min: f64 = if (isValidFloatingPoint(min_attr))
std.fmt.parseFloat(f64, min_attr) catch 0
else
0;
const max: f64 = if (isValidFloatingPoint(max_attr))
std.fmt.parseFloat(f64, max_attr) catch 100
else
100;
if (!isValidFloatingPoint(value)) {
return try formatFloat(frame.arena, min + (max - min) / 2);
}
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;
}
/// Format an f64 to its shortest decimal representation, arena-allocated.
fn formatFloat(arena: std.mem.Allocator, value: f64) ![]const u8 {
return std.fmt.allocPrint(arena, "{d}", .{value});
}
/// Parse a slice that must be ALL ASCII digits into a u32. Returns null if any non-digit or empty.
fn parseAllDigits(s: []const u8) ?u32 {
if (s.len == 0) return null;