diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html
index f7052f51..b78c8185 100644
--- a/src/browser/tests/element/html/input.html
+++ b/src/browser/tests/element/html/input.html
@@ -23,6 +23,47 @@
testing.expectEqual('radio', $('#radio1').type)
+
+
+
+
diff --git a/src/browser/tests/event/abort_controller.html b/src/browser/tests/event/abort_controller.html
index 050c14b2..4a14aee4 100644
--- a/src/browser/tests/event/abort_controller.html
+++ b/src/browser/tests/event/abort_controller.html
@@ -7,7 +7,7 @@
const signal = controller.signal;
testing.expectEqual(false, signal.aborted);
- testing.expectEqual(null, signal.reason);
+ testing.expectEqual(undefined, signal.reason);
}
@@ -123,7 +123,7 @@
const signal = AbortSignal.abort();
testing.expectEqual(true, signal.aborted);
- testing.expectEqual(null, signal.reason);
+ testing.expectEqual("AbortError", signal.reason);
}
@@ -211,3 +211,42 @@
testing.expectEqual(2, count); // Still 2, listener was removed
}
+
+
+
+
+
+
diff --git a/src/browser/tests/legacy/storage/local_storage.html b/src/browser/tests/legacy/storage/local_storage.html
index 4ad0b14f..c78f3889 100644
--- a/src/browser/tests/legacy/storage/local_storage.html
+++ b/src/browser/tests/legacy/storage/local_storage.html
@@ -9,7 +9,7 @@
localStorage.setItem('foo', 'bar');
testing.expectEqual(1, localStorage.length)
testing.expectEqual('bar', localStorage.getItem('foo'));
- testing.expectEqual('bar', localStorage.key(0));
+ testing.expectEqual('foo', localStorage.key(0));
testing.expectEqual(null, localStorage.key(1));
localStorage.removeItem('foo');
diff --git a/src/browser/tests/storage.html b/src/browser/tests/storage.html
index edd90979..7633293b 100644
--- a/src/browser/tests/storage.html
+++ b/src/browser/tests/storage.html
@@ -60,3 +60,31 @@
localStorage.clear();
testing.expectEqual(0, localStorage.length);
+
+
diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig
index 13718b97..d9f7c318 100644
--- a/src/browser/webapi/AbortController.zig
+++ b/src/browser/webapi/AbortController.zig
@@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
return self._signal;
}
-pub fn abort(self: *AbortController, reason: ?js.Object, page: *Page) !void {
- try self._signal.abort(reason, page);
+pub fn abort(self: *AbortController, reason_: ?js.Object, page: *Page) !void {
+ try self._signal.abort(if (reason_) |r| .{.js_obj = r} else null, page);
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig
index 40ac9e89..6f823032 100644
--- a/src/browser/webapi/AbortSignal.zig
+++ b/src/browser/webapi/AbortSignal.zig
@@ -18,6 +18,7 @@
const std = @import("std");
const js = @import("../js/js.zig");
+const log = @import("../../log.zig");
const Page = @import("../Page.zig");
const Event = @import("Event.zig");
@@ -27,15 +28,12 @@ const AbortSignal = @This();
_proto: *EventTarget,
_aborted: bool = false,
-_reason: ?js.Object = null,
+_reason: Reason = .undefined,
_on_abort: ?js.Function = null,
pub fn init(page: *Page) !*AbortSignal {
return page._factory.eventTarget(AbortSignal{
._proto = undefined,
- ._aborted = false,
- ._reason = null,
- ._on_abort = null,
});
}
@@ -43,7 +41,7 @@ pub fn getAborted(self: *const AbortSignal) bool {
return self._aborted;
}
-pub fn getReason(self: *const AbortSignal) ?js.Object {
+pub fn getReason(self: *const AbortSignal) Reason {
return self._reason;
}
@@ -63,14 +61,22 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget {
return self._proto;
}
-pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void {
- if (self._aborted) return;
+pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
+ if (self._aborted) {
+ return;
+ }
self._aborted = true;
// Store the abort reason (default to a simple string if none provided)
if (reason_) |reason| {
- self._reason = try reason.persist();
+ switch (reason) {
+ .js_obj => |js_obj| self._reason = .{.js_obj = try js_obj.persist()},
+ .string => |str| self._reason = .{.string = try page.dupeString(str)},
+ .undefined => self._reason = reason,
+ }
+ } else {
+ self._reason = .{.string = "AbortError"};
}
// Dispatch abort event
@@ -86,16 +92,59 @@ pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void {
// Static method to create an already-aborted signal
pub fn createAborted(reason_: ?js.Object, page: *Page) !*AbortSignal {
const signal = try init(page);
- try signal.abort(reason_, page);
+ try signal.abort(if (reason_) |r| .{.js_obj = r} else null, page);
return signal;
}
-pub fn throwIfAborted(self: *const AbortSignal) !void {
- if (self._aborted) {
- return error.Aborted;
- }
+pub fn createTimeout(delay: u32, page: *Page) !*AbortSignal {
+ const callback = try page.arena.create(TimeoutCallback);
+ callback.* = .{
+ .page = page,
+ .signal = try init(page),
+ };
+
+ try page.scheduler.add(callback, TimeoutCallback.run, delay, .{
+ .name = "AbortSignal.timeout",
+ });
+
+ return callback.signal;
}
+const ThrowIfAborted = union(enum) {
+ exception: js.Exception,
+ undefined: void,
+};
+pub fn throwIfAborted(self: *const AbortSignal, page: *Page) !ThrowIfAborted {
+ if (self._aborted) {
+ const exception = switch (self._reason) {
+ .string => |str| page.js.throw(str),
+ .js_obj => |js_obj| page.js.throw(try js_obj.toString()),
+ .undefined => page.js.throw("AbortError"),
+ };
+ return .{ .exception = exception };
+ }
+ return .undefined;
+}
+
+const Reason = union(enum) {
+ js_obj: js.Object,
+ string: []const u8,
+ undefined: void,
+};
+
+const TimeoutCallback = struct {
+ page: *Page,
+ signal: *AbortSignal,
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
+ self.signal.abort(.{.string = "TimeoutError"}, self.page) catch |err| {
+ log.warn(.app, "abort signal timeout", .{ .err = err });
+ };
+ return null;
+ }
+};
+
pub const JsApi = struct {
pub const bridge = js.Bridge(AbortSignal);
@@ -116,4 +165,5 @@ pub const JsApi = struct {
// Static method
pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true });
+ pub const timeout = bridge.function(AbortSignal.createTimeout, .{ .static = true });
};
diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig
index 83f42e20..7aee1d20 100644
--- a/src/browser/webapi/XMLSerializer.zig
+++ b/src/browser/webapi/XMLSerializer.zig
@@ -37,7 +37,10 @@ pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) !
} else {
try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page);
}
- return buf.written();
+ // Not sure about this trim. But `dump` is meant to display relatively
+ // pretty HTML, so it does include newlines, which can result in a trailing
+ // newline. XMLSerializer is a bit more strict.
+ return std.mem.trim(u8, buf.written(), &std.ascii.whitespace);
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig
index 75e691b5..8de457b6 100644
--- a/src/browser/webapi/element/Html.zig
+++ b/src/browser/webapi/element/Html.zig
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const js = @import("../../js/js.zig");
const reflect = @import("../../reflect.zig");
@@ -61,8 +62,6 @@ pub const Unknown = @import("html/Unknown.zig");
const HtmlElement = @This();
-const std = @import("std");
-
_type: Type,
_proto: *Element,
diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig
index 9c4593a9..865912d2 100644
--- a/src/browser/webapi/element/html/Input.zig
+++ b/src/browser/webapi/element/html/Input.zig
@@ -73,6 +73,7 @@ _default_value: ?[]const u8 = null,
_default_checked: bool = false,
_value: ?[]const u8 = null,
_checked: bool = false,
+_checked_dirty: bool = false,
_input_type: Type = .text,
pub fn asElement(self: *Input) *Element {
@@ -122,8 +123,9 @@ pub fn setChecked(self: *Input, checked: bool, page: *Page) !void {
if (checked and self._input_type == .radio) {
try self.uncheckRadioGroup(page);
}
- // This should _not_ call setAttribute. It updates the default state only
+ // This should _not_ call setAttribute. It updates the current state only
self._checked = checked;
+ self._checked_dirty = true;
}
pub fn getDefaultChecked(self: *const Input) bool {
@@ -160,6 +162,78 @@ pub fn setName(self: *Input, name: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe("name", name, page);
}
+pub fn getAccept(self: *const Input) []const u8 {
+ return self.asConstElement().getAttributeSafe("accept") orelse "";
+}
+
+pub fn setAccept(self: *Input, accept: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("accept", accept, page);
+}
+
+pub fn getAlt(self: *const Input) []const u8 {
+ return self.asConstElement().getAttributeSafe("alt") orelse "";
+}
+
+pub fn setAlt(self: *Input, alt: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("alt", alt, page);
+}
+
+pub fn getMaxLength(self: *const Input) i32 {
+ const attr = self.asConstElement().getAttributeSafe("maxlength") orelse return -1;
+ return std.fmt.parseInt(i32, attr, 10) catch -1;
+}
+
+pub fn setMaxLength(self: *Input, max_length: i32, page: *Page) !void {
+ if (max_length < 0) {
+ return error.NegativeValueNotAllowed;
+ }
+ var buf: [32]u8 = undefined;
+ const value = std.fmt.bufPrint(&buf, "{d}", .{max_length}) catch unreachable;
+ try self.asElement().setAttributeSafe("maxlength", value, page);
+}
+
+pub fn getSize(self: *const Input) i32 {
+ const attr = self.asConstElement().getAttributeSafe("size") orelse return 20;
+ const parsed = std.fmt.parseInt(i32, attr, 10) catch return 20;
+ return if (parsed == 0) 20 else parsed;
+}
+
+pub fn setSize(self: *Input, size: i32, page: *Page) !void {
+ if (size == 0) {
+ return error.ZeroNotAllowed;
+ }
+ if (size < 0) {
+ return self.asElement().setAttributeSafe("size", "20", page);
+ }
+
+ var buf: [32]u8 = undefined;
+ const value = std.fmt.bufPrint(&buf, "{d}", .{size}) catch unreachable;
+ try self.asElement().setAttributeSafe("size", value, page);
+}
+
+pub fn getSrc(self: *const Input, page: *Page) ![]const u8 {
+ const src = self.asConstElement().getAttributeSafe("src") orelse return "";
+ // If attribute is explicitly set (even if empty), resolve it against the base URL
+ return @import("../../URL.zig").resolve(page.call_arena, page.base(), src, .{});
+}
+
+pub fn setSrc(self: *Input, src: []const u8, page: *Page) !void {
+ const trimmed = std.mem.trim(u8, src, &std.ascii.whitespace);
+ try self.asElement().setAttributeSafe("src", trimmed, page);
+}
+
+pub fn getReadonly(self: *const Input) bool {
+ return self.asConstElement().getAttributeSafe("readonly") != null;
+}
+
+pub fn setReadonly(self: *Input, readonly: bool, page: *Page) !void {
+ if (readonly) {
+ try self.asElement().setAttributeSafe("readonly", "", page);
+ } else {
+ try self.asElement().removeAttribute("readonly", page);
+ }
+}
+
pub fn getRequired(self: *const Input) bool {
return self.asConstElement().getAttributeSafe("required") != null;
}
@@ -256,6 +330,12 @@ pub const JsApi = struct {
pub const disabled = bridge.accessor(Input.getDisabled, Input.setDisabled, .{});
pub const name = bridge.accessor(Input.getName, Input.setName, .{});
pub const required = bridge.accessor(Input.getRequired, Input.setRequired, .{});
+ pub const accept = bridge.accessor(Input.getAccept, Input.setAccept, .{});
+ pub const readOnly = bridge.accessor(Input.getReadonly, Input.setReadonly, .{});
+ pub const alt = bridge.accessor(Input.getAlt, Input.setAlt, .{});
+ pub const maxLength = bridge.accessor(Input.getMaxLength, Input.setMaxLength, .{});
+ pub const size = bridge.accessor(Input.getSize, Input.setSize, .{});
+ pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{});
pub const form = bridge.accessor(Input.getForm, null, .{});
};
@@ -291,10 +371,13 @@ pub const Build = struct {
.value => self._default_value = value,
.checked => {
self._default_checked = true;
- self._checked = true;
- // If setting a radio button to checked, uncheck others in the group
- if (self._input_type == .radio) {
- try self.uncheckRadioGroup(page);
+ // Only update checked state if it hasn't been manually modified
+ if (!self._checked_dirty) {
+ self._checked = true;
+ // If setting a radio button to checked, uncheck others in the group
+ if (self._input_type == .radio) {
+ try self.uncheckRadioGroup(page);
+ }
}
},
}
@@ -308,7 +391,10 @@ pub const Build = struct {
.value => self._default_value = null,
.checked => {
self._default_checked = false;
- self._checked = false;
+ // Only update checked state if it hasn't been manually modified
+ if (!self._checked_dirty) {
+ self._checked = false;
+ }
},
}
}
diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig
index 0d303cc9..c4fa83de 100644
--- a/src/browser/webapi/element/html/Script.zig
+++ b/src/browser/webapi/element/html/Script.zig
@@ -66,6 +66,14 @@ pub fn setType(self: *Script, value: []const u8, page: *Page) !void {
return self.asElement().setAttributeSafe("type", value, page);
}
+pub fn getNonce(self: *const Script) []const u8 {
+ return self.asConstElement().getAttributeSafe("nonce") orelse "";
+}
+
+pub fn setNonce(self: *Script, value: []const u8, page: *Page) !void {
+ return self.asElement().setAttributeSafe("nonce", value, page);
+}
+
pub fn getOnLoad(self: *const Script) ?js.Function {
return self._on_load;
}
@@ -109,6 +117,7 @@ pub const JsApi = struct {
pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{});
pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{});
+ pub const nonce = bridge.accessor(Script.getNonce, Script.setNonce, .{});
pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{});
pub const onerror = bridge.accessor(Script.getOnError, Script.setOnError, .{});
pub const noModule = bridge.accessor(Script.getNoModule, null, .{});
diff --git a/src/browser/webapi/storage/storage.zig b/src/browser/webapi/storage/storage.zig
index e5c8e22e..64dfae23 100644
--- a/src/browser/webapi/storage/storage.zig
+++ b/src/browser/webapi/storage/storage.zig
@@ -116,6 +116,7 @@ pub const Lookup = struct {
pub const removeItem = bridge.function(Lookup.removeItem, .{});
pub const clear = bridge.function(Lookup.clear, .{});
pub const key = bridge.function(Lookup.key, .{});
+ pub const @"[str]" = bridge.namedIndexed(Lookup.getItem, Lookup.setItem, null, .{ .null_as_undefined = true });
};
};
diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig
index e8a57997..622ae5e2 100644
--- a/src/main_legacy_test.zig
+++ b/src/main_legacy_test.zig
@@ -61,6 +61,13 @@ pub fn main() !void {
if (!std.mem.endsWith(u8, entry.basename, ".html")) {
continue;
}
+ if (std.mem.indexOf(u8, entry.basename, "navigation") != null) {
+ continue;
+ }
+ if (std.mem.indexOf(u8, entry.basename, "history") != null) {
+ continue;
+ }
+
if (filter) |f| {
if (std.mem.indexOf(u8, entry.path, f) == null) {
continue;