fix(input): mousedown focuses tabindex targets, not only contenteditable

focusEditingHostForMouseDown only walked contenteditable hosts, so a
div[tabindex=0] stayed unfocused after click. Replace it with
focusForMouseDown: still prefer the outermost editing host, then focus
the nearest mouse-focusable element (including tabindex=-1).

Call sites: actions.click, CDP triggerMousePress, WebDriver pointerDown.

Tests cover the MCP selector path, agent Page.click on the HTML fixture,
and a runtime-created child click (ancestor walk, non-focusable must not
steal focus, tabindex=-1 is mouse-focusable).
This commit is contained in:
Ramiro_quai committed 2026-09-07 23:31:09 -06:00
1 parent 46ac5094c5
commit e2d878eee8
6 files changed
+122 -11

No files matched your search

+2 -2
View File
@@ -98,8 +98,8 @@ pub fn click(node: *DOMNode, frame: *Frame) !void {
const suppress_mouse = try dispatchPointer(el, "pointerdown", 1, 0, frame);
if (!suppress_mouse) {
try dispatchMouse(el, "mousedown", 1, frame);
Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| {
lp.log.warn(.app, "click editable focus", .{ .err = err });
Frame.user_input.focusForMouseDown(frame, el) catch |err| {
lp.log.warn(.app, "click mousedown focus", .{ .err = err });
};
}
+47 -7
View File
@@ -209,7 +209,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
});
}
try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0);
try focusEditingHostForMouseDown(frame, target);
try focusForMouseDown(frame, target);
}
pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void {
@@ -353,9 +353,9 @@ fn isEditingHost(node: *Node) bool {
return std.ascii.eqlIgnoreCase(value, "false") == false;
}
// A mousedown on editable content focuses its editing host: the outermost
// element of the contiguous editable chain containing the target.
pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void {
// Find the outermost element of the contiguous editable chain containing the
// target.
fn outermostEditingHost(target: *Element) ?*Element {
var node: ?*Node = target.asNode();
var editable: ?*Node = null;
while (node) |n| : (node = n._parent) {
@@ -364,15 +364,55 @@ pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void {
break;
}
}
var host = editable orelse return;
var host = editable orelse return null;
while (host._parent) |p| {
if (!isEditingHost(p)) {
break;
}
host = p;
}
const host_element = host.is(Element) orelse return;
try host_element.focus(frame);
return host.is(Element);
}
/// `null` means the element is not mouse-focusable. Unlike sequential focus,
/// any explicit tabindex value, including a negative one, is mouse-focusable.
fn mouseFocusTabIndex(el: *Element) ?i32 {
if (el.isDisabled()) return null;
if (el.is(Element.Html) == null) return null;
if (el.getAttributeSafe(comptime .wrap("tabindex"))) |attr| {
return Element.Html.parseInteger(attr) orelse 0;
}
const native = 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 if (native) 0 else null;
}
fn isMouseFocusable(el: *Element) bool {
return mouseFocusTabIndex(el) != null;
}
/// Mousedown default action: focus the editing host if the click is inside
/// one, otherwise the nearest mouse-focusable element (self or ancestor).
pub fn focusForMouseDown(frame: *Frame, target: *Element) !void {
if (outermostEditingHost(target)) |host| {
try host.focus(frame);
return;
}
var node: ?*Node = target.asNode();
while (node) |n| : (node = n._parent) {
const el = n.is(Element) orelse continue;
if (isMouseFocusable(el)) {
try el.focus(frame);
return;
}
}
}
// Per the DOM dispatch algorithm, a click's activation target is the event
+2
View File
@@ -21,6 +21,8 @@
<input id="rad" type="radio" name="group1">
<button id="btnPreventDefault">Prevent Default</button>
<button id="btnDisabled" disabled>Disabled</button>
<div id="focusTarget" tabindex="0" onclick="window.focusTargetFocused = document.activeElement === this;">Focus me</div>
<div id="plain" onclick="window.focusAfterPlain = document.activeElement.id;">Not focusable</div>
<script>
window.seq = [];
const btn = document.getElementById('btn');
+2 -2
View File
@@ -299,8 +299,8 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
dispatchTouch(el, "touchstart", frame);
} else {
dispatchMouse(el, "mousedown", button, buttonsMask(button), click_count, frame);
Frame.user_input.focusEditingHostForMouseDown(frame, el) catch |err| {
log.warn(.app, "webdriver editable focus", .{ .err = err });
Frame.user_input.focusForMouseDown(frame, el) catch |err| {
log.warn(.app, "webdriver mousedown focus", .{ .err = err });
};
}
} else if (action_type.eql(comptime .wrap("pointerUp"))) {
+19
View File
@@ -1084,6 +1084,24 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
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"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null);
out.clearRetainingCapacity();
}
{
const inp = frame.document.getElementById("inp", frame).?.asNode();
const inp_id = (try server.active_session.registry.register(inp)).id;
@@ -1189,6 +1207,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
\\ ]) &&
\\ JSON.stringify(window.seqPrevented) === JSON.stringify(['pointerdown', 'pointerup', 'click']) &&
\\ window.disabledMousedowned === false &&
\\ window.focusTargetFocused === true && window.focusAfterPlain === 'focusTarget' &&
\\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true &&
+50
View File
@@ -1441,6 +1441,56 @@ test "agent script runtime: tool errors throw and stop execution" {
);
}
test "agent script runtime: selector click preserves pointer mouse semantics" {
defer testing.test_session.closeAllPages();
var registry = CDPNode.Registry.init(testing.allocator);
defer registry.deinit();
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
defer runtime.deinit();
try runTestScript(runtime,
\\const page = new Page();
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
\\page.click("#btn");
\\const full = page.evaluate("JSON.stringify(window.seq)");
\\if (full !== '["pointerdown:0:1:mouse:true","mousedown:0:1::true","pointerup:0:0:mouse:true","mouseup:0:0::true","click:0:0:mouse:true"]') throw new Error("wrong full click sequence: " + full);
\\page.click("#btnPreventDefault");
\\const suppressed = page.evaluate("JSON.stringify(window.seqPrevented)");
\\if (suppressed !== '["pointerdown","pointerup","click"]') throw new Error("wrong suppressed sequence: " + suppressed);
\\page.click("#btnDisabled");
\\if (page.evaluate("String(window.disabledMousedowned)") !== "false") throw new Error("disabled button received mousedown");
\\page.click("#focusTarget");
\\if (page.evaluate("document.activeElement.id") !== "focusTarget") throw new Error("mousedown focus default action was lost");
);
}
// Different workflow from the fixture above: the tabindex node is created at
// runtime, the click lands on a child (ancestor walk), a following click on a
// non-focusable node must not steal focus, and tabindex=-1 is still mouse-focusable.
test "agent script runtime: dynamic tabindex child click focuses ancestor" {
defer testing.test_session.closeAllPages();
var registry = CDPNode.Registry.init(testing.allocator);
defer registry.deinit();
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
defer runtime.deinit();
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)");
\\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("document.activeElement.id") !== "dynFocus") throw new Error("plain click stole focus: " + page.evaluate("document.activeElement && document.activeElement.id"));
\\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"));
);
}
test "agent script runtime: builtin argument marshalling (positional + options)" {
defer testing.test_session.closeAllPages();