Add support for target attribute on anchors and forms

This commit is contained in:
Karl Seguin
2026-03-11 15:49:30 +08:00
parent 487ee18358
commit ee637c3662
6 changed files with 169 additions and 20 deletions

View File

@@ -558,12 +558,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;
@@ -759,6 +756,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();
}
@@ -3028,9 +3029,9 @@ const NavigationType = enum {
};
const Navigation = union(NavigationType) {
form: *Node,
form: *Page,
script: ?*Page,
anchor: *Node,
anchor: *Page,
iframe: *IFrame,
};
@@ -3042,6 +3043,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) {
@@ -3080,29 +3144,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);
@@ -3195,6 +3257,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);
@@ -3237,7 +3318,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.

View File

@@ -0,0 +1,2 @@
<!DOCTYPE html>
a-page

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe name=f1 id=frame1></iframe>
<a id=l1 target=f1 href=support/page.html></a>
<script id=anchor>
$('#l1').click();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#frame1').contentDocument.documentElement.outerHTML);
});
</script>
<script id=form>
{
let frame2 = document.createElement('iframe');
frame2.name = 'frame2';
document.documentElement.appendChild(frame2);
let form = document.createElement('form');
form.target = 'frame2';
form.action = 'support/page.html';
form.submit();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', frame2.contentDocument.documentElement.outerHTML);
});
}
</script>
<iframe name=frame3 id=f3></iframe>
<form target="_top" action="support/page.html">
<input type=submit id=submit1 formtarget="frame3">
</form>
<script id=formtarget>
{
$('#submit1').click();
testing.eventually(() => {
testing.expectEqual('<html><head></head><body>a-page\n</body></html>', $('#f3').contentDocument.documentElement.outerHTML);
});
}
</script>

View File

@@ -492,6 +492,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);

View File

@@ -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, .{});

View File

@@ -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, .{});
};