diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index cd49a8649..858ff5d90 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -92,6 +92,16 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat } } +/// dispatch() drops its reference, and with it the event, before returning; +/// this keeps the event alive so the caller can learn whether a listener +/// called preventDefault(). +pub fn dispatchCancelable(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!bool { + event.acquireRef(); + defer event.releaseRef(self.frame._page); + try self.dispatch(target, event); + return event.getDefaultPrevented(); +} + // Resolves the Window's property event handler for the given event type. fn windowInlineHandler(window: *Window, typ: lp.String) ?js.Function.Global { const global_event_handlers = @import("webapi/global_event_handlers.zig"); diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 40d66d523..8f76c99fe 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -39,14 +39,13 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { }; } -fn dispatch(el: *Element, event: *Event, comptime typ: []const u8, frame: *Frame) !void { - frame._event_manager.dispatch(el.asEventTarget(), event) catch |err| { +fn dispatch(el: *Element, event: *Event, comptime typ: []const u8, frame: *Frame) !bool { + return frame._event_manager.dispatchCancelable(el.asEventTarget(), event) catch |err| { lp.log.err(.app, "click " ++ typ ++ " failed", .{ .err = err }); return error.ActionFailed; }; } -/// Dispatches a trusted pointer event and reports whether it was cancelled. fn dispatchPointer(el: *Element, comptime typ: []const u8, buttons: u16, detail: u32, frame: *Frame) !bool { const event: *PointerEvent = try .initTrusted(typ, .{ .bubbles = true, @@ -59,15 +58,7 @@ fn dispatchPointer(el: *Element, comptime typ: []const u8, buttons: u16, detail: .isPrimary = true, .pressure = if (buttons != 0) 0.5 else 0.0, }, frame); - - // Keep the event alive past dispatch (which runs handlers/microtasks) so - // we can read _prevent_default afterwards. - const base_event = event.asEvent(); - base_event.acquireRef(); - defer base_event.releaseRef(frame._page); - - try dispatch(el, base_event, typ, frame); - return base_event.getDefaultPrevented(); + return dispatch(el, event.asEvent(), typ, frame); } fn dispatchMouse(el: *Element, comptime typ: []const u8, buttons: u16, frame: *Frame) !bool { @@ -78,17 +69,11 @@ fn dispatchMouse(el: *Element, comptime typ: []const u8, buttons: u16, frame: *F .buttons = buttons, .detail = 1, }, frame); - - const base_event = event.asEvent(); - base_event.acquireRef(); - defer base_event.releaseRef(frame._page); - - try dispatch(el, base_event, typ, frame); - return base_event.getDefaultPrevented(); + return dispatch(el, event.asEvent(), typ, frame); } -/// A full trusted primary-button click sequence, as a real user click would -/// produce: pointerdown, mousedown, pointerup, mouseup, click. +/// The trusted primary-button gesture a real user click produces; widgets key +/// off pointerdown/mousedown, not click alone. pub fn click(node: *DOMNode, frame: *Frame) !void { const el = node.is(Element) orelse return error.InvalidNodeType; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 6a3121b5f..2fbc25d61 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -194,13 +194,7 @@ fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u .button = button, .detail = detail, }, frame); - - const base_event = event.asEvent(); - base_event.acquireRef(); - defer base_event.releaseRef(frame._page); - - try frame._event_manager.dispatch(target.asEventTarget(), base_event); - return base_event.getDefaultPrevented(); + return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent()); } pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { @@ -332,12 +326,7 @@ fn deltaToScroll(d: f64) i32 { fn hasClickActivationBehavior(node: *Node) bool { const element = node.is(Element) orelse return false; - const html_element = element.is(Element.Html) orelse { - if (element.is(Element.Svg.Graphics.A) != null) { - return svgAnchorHref(element) != null; - } - return false; - }; + const html_element = element.is(Element.Html) orelse return isSvgLink(element); return switch (html_element._type) { .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, @@ -352,6 +341,23 @@ fn svgAnchorHref(element: *Element) ?[]const u8 { return element.getAttributeSafe(comptime .wrap("href")) orelse element.getAttributeSafe(comptime .wrap("xlink:href")); } +fn isSvgLink(element: *Element) bool { + return element.is(Element.Svg.Graphics.A) != null and svgAnchorHref(element) != null; +} + +/// Focusable without a tabindex attribute. +fn isNativelyFocusable(el: *Element) bool { + if (el.is(Element.Html) == null) { + return isSvgLink(el); + } + return switch (el.getTag()) { + .button, .select, .textarea, .iframe => true, + .input => el.as(Element.Html.Input)._input_type != .hidden, + .anchor, .area => el.getAttributeSafe(comptime .wrap("href")) != null, + else => false, + }; +} + // Clicks on editable content are for editing: they don't activate the // element or any enclosing link. // "contenteditable" is 15 bytes — past the comptime SSO limit — so the @@ -383,42 +389,19 @@ fn outermostEditingHost(target: *Element) ?*Element { return host.is(Element); } -/// Unlike sequential focus, any explicit, parseable tabindex value, including -/// a negative one, is mouse-focusable — on any element, not just HTML ones, so -/// this is checked before the HTML-only guard below. An unparsable tabindex is -/// treated as if the attribute were absent (HTML §6.6.3), so native -/// focusability still applies — mirrors HtmlElement.getTabIndex's -/// parse-failure fallthrough. +/// Unlike sequential focus, a negative tabindex is still mouse-focusable, and +/// an unparsable one counts as absent (HTML §6.6.3), not as "not focusable". fn isMouseFocusable(el: *Element) bool { if (el.isDisabled()) return false; if (el.getAttributeSafe(comptime .wrap("tabindex"))) |attr| { - if (Element.Html.parseInteger(attr)) |_| { - return true; - } + if (Element.Html.parseInteger(attr) != null) return true; } - - if (el.is(Element.Html) == null) { - // Mirrors hasClickActivationBehavior: an SVG link is focusable by the - // same href it activates on. - if (el.is(Element.Svg.Graphics.A) != null) { - return svgAnchorHref(el) != null; - } - return false; - } - - return switch (el.getTag()) { - .button, .select, .textarea, .iframe => true, - .input => el.as(Element.Html.Input)._input_type != .hidden, - .anchor, .area => el.getAttributeSafe(comptime .wrap("href")) != null, - else => false, - }; + return isNativelyFocusable(el); } -/// Mousedown default action: focus the editing host if the click is inside -/// one, otherwise the nearest mouse-focusable element (self or ancestor). If -/// no ancestor is mouse-focusable, blur whatever was previously focused — -/// a mousedown outside any focusable element moves focus to the body. +/// Mousedown default action. A mousedown outside any focusable element moves +/// focus to the body. pub fn focusForMouseDown(frame: *Frame, target: *Element) !void { if (outermostEditingHost(target)) |host| { try host.focus(frame); @@ -907,17 +890,9 @@ fn moveFocus(frame: *Frame, forward: bool) !void { break :blk 0; } - // no tab index, maybe this item isn't focusable.. - const focusable = switch (candidate.getTag()) { - .button, .select, .textarea, .iframe => true, - .input => candidate.as(Element.Html.Input)._input_type != .hidden, - .anchor, .area => candidate.getAttributeSafe(comptime .wrap("href")) != null, - else => false, - }; - if (focusable == false) { + if (!isNativelyFocusable(candidate)) { continue; } - break :blk 0; }; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 70b046bca..392d1b817 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1126,6 +1126,12 @@ pub fn focus(self: *Element, frame: *Frame) !void { return; } + const doc = self.asNode().ownerDocument(frame) orelse frame.document; + const old_active = doc._active_element; + if (old_active == self) { + return; + } + // Per HTML spec §6.4.4, an element must be "being rendered" (not // display:none on self or any ancestor) to be focusable. if (!self.checkVisibilityCached(null, frame, .materialize)) { @@ -1135,15 +1141,9 @@ pub fn focus(self: *Element, frame: *Frame) !void { const FocusEvent = @import("event/FocusEvent.zig"); const new_target = self.asEventTarget(); - const doc = self.asNode().ownerDocument(frame) orelse frame.document; - const old_active = doc._active_element; doc._active_element = self; if (old_active) |old| { - if (old == self) { - return; - } - const old_target = old.asEventTarget(); // Dispatch blur on old element (no bubble, composed) diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 5f7d2ae3e..1df807ee8 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -585,12 +585,10 @@ fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u log.warn(.app, "webdriver mouse event", .{ .err = err, .type = typ }); return false; }; - const base_event = event.asEvent(); - base_event.acquireRef(); - defer base_event.releaseRef(frame._page); - - dispatch(el.asEventTarget(), base_event, frame, typ); - return base_event.getDefaultPrevented(); + return frame._event_manager.dispatchCancelable(el.asEventTarget(), event.asEvent()) catch |err| { + log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ }); + return false; + }; } fn dispatchWheel(el: *Element, delta_x: i32, delta_y: i32, frame: *Frame) void { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 4ad8c7577..b90ba9d69 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -1062,41 +1062,8 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked out.clearRetainingCapacity(); } - { - const btn = frame.document.getElementById("btnPreventDefault", frame).?.asNode(); - const btn_id = (try server.active_session.registry.register(btn)).id; - var btn_id_buf: [12]u8 = undefined; - const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; - const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); - try router.handleMessage(server, aa, click_msg); - try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); - out.clearRetainingCapacity(); - } - - { - const btn = frame.document.getElementById("btnDisabled", frame).?.asNode(); - const btn_id = (try server.active_session.registry.register(btn)).id; - var btn_id_buf: [12]u8 = undefined; - const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; - const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); - try router.handleMessage(server, aa, click_msg); - try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); - out.clearRetainingCapacity(); - } - - { - const msg = - \\{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"click","arguments":{"selector":"#focusTarget"}}} - ; - try router.handleMessage(server, aa, msg); - try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); - out.clearRetainingCapacity(); - } - - { - const msg = - \\{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"click","arguments":{"selector":"#plain"}}} - ; + for ([_][]const u8{ "#btnPreventDefault", "#btnDisabled", "#focusTarget", "#plain" }) |selector| { + const msg = try std.fmt.allocPrint(aa, "{{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\",\"params\":{{\"name\":\"click\",\"arguments\":{{\"selector\":\"{s}\"}}}}}}", .{selector}); try router.handleMessage(server, aa, msg); try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); out.clearRetainingCapacity(); diff --git a/src/script/Runtime.zig b/src/script/Runtime.zig index 1836179ea..1fe92f243 100644 --- a/src/script/Runtime.zig +++ b/src/script/Runtime.zig @@ -1441,12 +1441,8 @@ test "agent script runtime: tool errors throw and stop execution" { ); } -// Complements the fixture-driven MCP test in tools.zig: here the nodes are -// created at runtime. Covers the ancestor walk, blur-to-body on a non-focusable -// node, tabindex=-1 still mouse-focusable, an unparsable tabindex on a plain div -// not focusable but on a button falling through to native focusability, -// preventDefault() on mousedown preserving focus, editing hosts winning over -// their contents, and an explicit tabindex on a non-HTML (SVG) element. +// Complements the fixture-driven MCP test in tools.zig with nodes created at +// runtime. test "agent script runtime: mousedown focus follows mouse-focusability rules" { defer testing.test_session.closeAllPages(); @@ -1459,44 +1455,51 @@ test "agent script runtime: mousedown focus follows mouse-focusability rules" { try runTestScript(runtime, \\const page = new Page(); \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html"); - \\page.evaluate("const p=document.createElement('div');p.id='dynFocus';p.setAttribute('tabindex','0');const s=document.createElement('span');s.id='dynChild';s.textContent='x';p.appendChild(s);document.body.appendChild(p);const n=document.createElement('div');n.id='dynNeg';n.setAttribute('tabindex','-1');n.textContent='neg';document.body.appendChild(n);const b=document.createElement('div');b.id='dynBad';b.setAttribute('tabindex','abc');b.textContent='bad';document.body.appendChild(b);const btn=document.createElement('button');btn.id='dynBadBtn';btn.setAttribute('tabindex','abc');btn.textContent='badbtn';btn.addEventListener('mouseup',function(){window.badBtnFocusAtMouseup=document.activeElement.id;});document.body.appendChild(btn)"); + \\const active = () => page.evaluate("document.activeElement === document.body ? 'body' : document.activeElement.id"); + \\const expectActive = (id, what) => { const got = active(); if (got !== id) throw new Error(what + " (active: " + got + ")"); }; + \\page.evaluate(` + \\ const add = (tag, id, attrs = {}, parent = document.body, ns = null) => { + \\ const e = ns ? document.createElementNS(ns, tag) : document.createElement(tag); + \\ e.id = id; + \\ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); + \\ parent.appendChild(e); + \\ return e; + \\ }; + \\ add('span', 'dynChild', {}, add('div', 'dynFocus', { tabindex: '0' })).textContent = 'x'; + \\ add('div', 'dynNeg', { tabindex: '-1' }).textContent = 'neg'; + \\ add('div', 'dynBad', { tabindex: 'abc' }).textContent = 'bad'; + \\ add('button', 'dynBadBtn', { tabindex: 'abc' }).addEventListener('mouseup', () => { window.badBtnFocusAtMouseup = document.activeElement.id; }); + \\ add('div', 'toolbarBtn').addEventListener('mousedown', (e) => e.preventDefault()); + \\ add('span', 'dynHostSpan', {}, add('div', 'dynHost', { contenteditable: 'true' })).textContent = 'hs'; + \\ add('span', 'dynInnerSpan', {}, add('div', 'dynInner', { contenteditable: 'true' }, add('div', 'dynOuter', { contenteditable: 'true' }))).textContent = 'is'; + \\ const SVG = 'http://www.w3.org/2000/svg'; + \\ add('rect', 'dynSvgRect', { tabindex: '0', width: '100', height: '40' }, add('svg', 'dynSvg', {}, document.body, SVG), SVG); + \\`); \\page.click("#dynChild"); - \\if (page.evaluate("document.activeElement.id") !== "dynFocus") throw new Error("child click did not focus tabindex ancestor: " + page.evaluate("document.activeElement && document.activeElement.id")); - \\page.click("#plain"); - \\if (page.evaluate("String(document.activeElement === document.body)") !== "true") throw new Error("plain click did not blur to body: " + page.evaluate("document.activeElement && document.activeElement.id")); + \\expectActive("dynFocus", "child click did not focus tabindex ancestor"); \\page.click("#dynNeg"); - \\if (page.evaluate("document.activeElement.id") !== "dynNeg") throw new Error("tabindex=-1 was not mouse-focusable: " + page.evaluate("document.activeElement && document.activeElement.id")); + \\expectActive("dynNeg", "tabindex=-1 was not mouse-focusable"); \\page.click("#dynBad"); - \\if (page.evaluate("String(document.activeElement === document.body)") !== "true") throw new Error("unparsable tabindex was mouse-focusable: " + page.evaluate("document.activeElement && document.activeElement.id")); + \\expectActive("body", "unparsable tabindex was mouse-focusable"); \\page.click("#dynBadBtn"); - \\// Sampled at mouseup, not after the click: a button's click activation - \\// behavior focuses it unconditionally, which would mask what mousedown did. - \\if (page.evaluate("window.badBtnFocusAtMouseup") !== "dynBadBtn") throw new Error("unparsable tabindex on a native button lost native mousedown focusability: " + page.evaluate("String(window.badBtnFocusAtMouseup)")); - \\// Toolbar idiom: preventDefault() on mousedown preserves existing focus - \\page.evaluate("const t=document.createElement('div');t.id='toolbarBtn';t.addEventListener('mousedown',function(e){e.preventDefault();});document.body.appendChild(t)"); + \\// Sampled at mouseup: click activation focuses a button regardless of + \\// what mousedown decided, which would mask the native focusability. + \\if (page.evaluate("window.badBtnFocusAtMouseup") !== "dynBadBtn") throw new Error("unparsable tabindex on a native button lost native mousedown focusability"); + \\// Toolbar idiom: preventDefault() on mousedown preserves existing focus. \\page.click("#inp"); - \\if (page.evaluate("document.activeElement.id") !== "inp") throw new Error("setup failed: activeElement not inp"); + \\expectActive("inp", "setup failed"); \\page.click("#toolbarBtn"); - \\if (page.evaluate("document.activeElement.id") !== "inp") throw new Error("preventDefault on mousedown did not protect focus (toolbar idiom): " + page.evaluate("document.activeElement && document.activeElement.id")); - \\// Editing hosts: the click focuses the host, not what is inside it. - \\// Verified against Chrome 152 with trusted CDP input: a plain span, a - \\// button, a [tabindex] div and an inside a contenteditable all - \\// leave focus on the host, and a nested host resolves to the OUTER one. - \\page.evaluate("const h=document.createElement('div');h.id='dynHost';h.setAttribute('contenteditable','true');const hs=document.createElement('span');hs.id='dynHostSpan';hs.textContent='hs';h.appendChild(hs);document.body.appendChild(h)"); + \\expectActive("inp", "preventDefault on mousedown did not protect focus"); + \\// Verified against Chrome: a click inside a contenteditable focuses the + \\// host, and a nested host resolves to the outer one. \\page.click("#dynHostSpan"); - \\if (page.evaluate("document.activeElement.id") !== "dynHost") throw new Error("span inside contenteditable did not focus host: " + page.evaluate("document.activeElement && document.activeElement.id")); - \\// Nested contenteditable: the OUTER host wins, not the inner one. - \\page.evaluate("const out=document.createElement('div');out.id='dynOuter';out.setAttribute('contenteditable','true');const inn=document.createElement('div');inn.id='dynInner';inn.setAttribute('contenteditable','true');const is=document.createElement('span');is.id='dynInnerSpan';is.textContent='is';inn.appendChild(is);out.appendChild(inn);document.body.appendChild(out)"); + \\expectActive("dynHost", "span inside contenteditable did not focus host"); \\page.click("#dynInnerSpan"); - \\if (page.evaluate("document.activeElement.id") !== "dynOuter") throw new Error("nested contenteditable did not focus the outer host: " + page.evaluate("document.activeElement && document.activeElement.id")); - \\// An explicit tabindex is focusable on a non-HTML element too: the - \\// HTML-only guard must not shadow it, or the new blur fallback would - \\// drop focus to body on a click Chrome focuses. - \\page.evaluate("const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');const r=document.createElementNS('http://www.w3.org/2000/svg','rect');r.id='dynSvgRect';r.setAttribute('tabindex','0');r.setAttribute('width','100');r.setAttribute('height','40');svg.appendChild(r);document.body.appendChild(svg)"); + \\expectActive("dynOuter", "nested contenteditable did not focus the outer host"); + \\// An explicit tabindex is focusable on a non-HTML element too. \\page.click("#inp"); - \\if (page.evaluate("document.activeElement.id") !== "inp") throw new Error("setup failed: activeElement not inp"); \\page.click("#dynSvgRect"); - \\if (page.evaluate("document.activeElement.id") !== "dynSvgRect") throw new Error("svg [tabindex] click did not focus the svg element: " + page.evaluate("document.activeElement && document.activeElement.id")); + \\expectActive("dynSvgRect", "svg [tabindex] click did not focus the svg element"); ); }