mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 17:22:43 -04:00
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:
1 parent
46ac5094c5
commit
e2d878eee8
6 files changed
+122
-11
No files matched your search
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"))) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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, ®istry);
|
||||
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, ®istry);
|
||||
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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user