diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 889e0d3c..e61bee90 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -562,12 +562,9 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: }; const target = switch (nt) { + .form, .anchor => |p| p, .script => |p| p orelse originator, .iframe => |iframe| iframe._window.?._page, // only an frame with existing content (i.e. a window) can be navigated - .anchor, .form => |node| blk: { - const doc = node.ownerDocument(originator) orelse break :blk originator; - break :blk doc._page orelse originator; - }, }; const session = target._session; @@ -763,6 +760,10 @@ fn _documentIsComplete(self: *Page) !void { try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" }); } + if (comptime IS_DEBUG) { + log.debug(.page, "load", .{ .url = self.url, .type = self._type }); + } + self.notifyParentLoadComplete(); } @@ -3106,9 +3107,9 @@ const NavigationType = enum { }; const Navigation = union(NavigationType) { - form: *Node, + form: *Page, script: ?*Page, - anchor: *Node, + anchor: *Page, iframe: *IFrame, }; @@ -3120,6 +3121,69 @@ pub const QueuedNavigation = struct { navigation_type: NavigationType, }; +/// Resolves a target attribute value (e.g., "_self", "_parent", "_top", or frame name) +/// to the appropriate Page to navigate. +/// Returns null if the target is "_blank" (which would open a new window/tab). +/// Note: Callers should handle empty target separately (for owner document resolution). +pub fn resolveTargetPage(self: *Page, target_name: []const u8) ?*Page { + if (std.ascii.eqlIgnoreCase(target_name, "_self")) { + return self; + } + + if (std.ascii.eqlIgnoreCase(target_name, "_blank")) { + return null; + } + + if (std.ascii.eqlIgnoreCase(target_name, "_parent")) { + return self.parent orelse self; + } + + if (std.ascii.eqlIgnoreCase(target_name, "_top")) { + var page = self; + while (page.parent) |p| { + page = p; + } + return page; + } + + // Named frame lookup: search current page's descendants first, then from root + // This follows the HTML spec's "implementation-defined" search order. + if (findFrameByName(self, target_name)) |frame_page| { + return frame_page; + } + + // If not found in descendants, search from root (catches siblings and ancestors' descendants) + var root = self; + while (root.parent) |p| { + root = p; + } + if (root != self) { + if (findFrameByName(root, target_name)) |frame_page| { + return frame_page; + } + } + + // If no frame found with that name, navigate in current page + // (this matches browser behavior - unknown targets act like _self) + return self; +} + +fn findFrameByName(page: *Page, name: []const u8) ?*Page { + for (page.frames.items) |frame| { + if (frame.iframe) |iframe| { + const frame_name = iframe.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; + if (std.mem.eql(u8, frame_name, name)) { + return frame; + } + } + // Recursively search child frames + if (findFrameByName(frame, name)) |found| { + return found; + } + } + return null; +} + pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return; if (comptime IS_DEBUG) { @@ -3158,29 +3222,27 @@ pub fn handleClick(self: *Page, target: *Node) !void { return; } - // Check target attribute - don't navigate if opening in new window/tab - const target_val = anchor.getTarget(); - if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { - log.warn(.not_implemented, "a.target", .{ .type = self._type, .url = self.url }); - return; - } - if (try element.hasAttribute(comptime .wrap("download"), self)) { log.warn(.browser, "a.download", .{ .type = self._type, .url = self.url }); return; } - // TODO: We need to support targets properly, but this is the most - // common case: a click on an anchor navigates the page/frame that - // anchor is in. + const target_page = blk: { + const target_name = anchor.getTarget(); + if (target_name.len == 0) { + break :blk target.ownerPage(self); + } + break :blk self.resolveTargetPage(target_name) orelse { + log.warn(.not_implemented, "target", .{ .type = self._type, .url = self.url, .target = target_name }); + return; + }; + }; - // ownerDocument only returns null when `target` is a document, which - // it is NOT in this case. Even for a detched node, it'll return self.document try element.focus(self); try self.scheduleNavigation(href, .{ .reason = .script, .kind = .{ .push = null }, - }, .{ .anchor = target }); + }, .{ .anchor = target_page }); }, .input => |input| { try element.focus(self); @@ -3273,6 +3335,25 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form const form_element = form.asElement(); + const target_name_: ?[]const u8 = blk: { + if (submitter_) |submitter| { + if (submitter.getAttributeSafe(comptime .wrap("formtarget"))) |ft| { + break :blk ft; + } + } + break :blk form_element.getAttributeSafe(comptime .wrap("target")); + }; + + const target_page = blk: { + const target_name = target_name_ orelse { + break :blk form_element.asNode().ownerPage(self); + }; + break :blk self.resolveTargetPage(target_name) orelse { + log.warn(.not_implemented, "target", .{ .type = self._type, .url = self.url, .target = target_name }); + return; + }; + }; + if (submit_opts.fire_event) { const submit_event = try Event.initTrusted(comptime .wrap("submit"), .{ .bubbles = true, .cancelable = true }, self); @@ -3315,7 +3396,8 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form } else { action = try URL.concatQueryString(arena, action, buf.written()); } - return self.scheduleNavigationWithArena(arena, action, opts, .{ .form = form_element.asNode() }); + + return self.scheduleNavigationWithArena(arena, action, opts, .{ .form = target_page }); } // insertText is a shortcut to insert text into the active element. diff --git a/src/browser/tests/frames/support/page.html b/src/browser/tests/frames/support/page.html new file mode 100644 index 00000000..147954f4 --- /dev/null +++ b/src/browser/tests/frames/support/page.html @@ -0,0 +1,2 @@ + +a-page diff --git a/src/browser/tests/frames/target.html b/src/browser/tests/frames/target.html new file mode 100644 index 00000000..3c4c7f36 --- /dev/null +++ b/src/browser/tests/frames/target.html @@ -0,0 +1,42 @@ + + + + + + + + + + +
+ +
+ + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 7bfa7cca..3673a1e7 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -493,6 +493,11 @@ pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document { return page.document; } +pub fn ownerPage(self: *const Node, default: *Page) *Page { + const doc = self.ownerDocument(default) orelse return default; + return doc._page orelse default; +} + pub fn isSameDocumentAs(self: *const Node, other: *const Node, page: *const Page) bool { // Get the root document for each node const self_doc = if (self._type == .document) self._type.document else self.ownerDocument(page); diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig index c4fdf260..24e8433e 100644 --- a/src/browser/webapi/element/html/Form.zig +++ b/src/browser/webapi/element/html/Form.zig @@ -100,6 +100,14 @@ pub fn setAction(self: *Form, value: []const u8, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("action"), .wrap(value), page); } +pub fn getTarget(self: *Form) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("target")) orelse ""; +} + +pub fn setTarget(self: *Form, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe(comptime .wrap("target"), .wrap(value), page); +} + pub fn getLength(self: *Form, page: *Page) !u32 { const elements = try self.getElements(page); return elements.length(page); @@ -120,6 +128,7 @@ pub const JsApi = struct { pub const name = bridge.accessor(Form.getName, Form.setName, .{}); pub const method = bridge.accessor(Form.getMethod, Form.setMethod, .{}); pub const action = bridge.accessor(Form.getAction, Form.setAction, .{}); + pub const target = bridge.accessor(Form.getTarget, Form.setTarget, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{}); pub const submit = bridge.function(Form.submit, .{}); diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index d912dd41..79bf3f7f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -65,6 +65,14 @@ pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void { } } +pub fn getName(self: *IFrame) []const u8 { + return self.asElement().getAttributeSafe(comptime .wrap("name")) orelse ""; +} + +pub fn setName(self: *IFrame, value: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), page); +} + pub const JsApi = struct { pub const bridge = js.Bridge(IFrame); @@ -75,6 +83,7 @@ pub const JsApi = struct { }; pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{}); + pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{}); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); };