From 35e13e4d6472809409c5f0e18952dc8239ff1123 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 11 Sep 2026 06:02:27 +0800 Subject: [PATCH] dom.focus only on focusable elements --- src/browser/frame/user_input.zig | 31 ++-------------------- src/browser/webapi/Element.zig | 35 +++++++++++++++++++++++++ src/server/cdp/domains/dom.zig | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 70889bb85..ecb091118 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -853,37 +853,10 @@ fn moveFocus(frame: *Frame, forward: bool) !void { var tw = TreeWalker.Full.Elements.init(document.asNode(), .{}); while (tw.next()) |candidate| { - if (candidate.isDisabled()) { + const candidate_tab_index = candidate.focusTabIndex() orelse continue; + if (candidate_tab_index < 0) { continue; } - if (candidate.is(Element.Html) == null) { - continue; - } - - const candidate_tab_index = blk: { - if (candidate.getAttributeInterned("tabindex")) |attr| { - if (Element.Html.parseInteger(attr)) |tab_index| { - if (tab_index < 0) { - continue; - } - break :blk tab_index; - } - 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.getAttributeInterned("href") != null, - else => false, - }; - if (focusable == false) { - continue; - } - - break :blk 0; - }; if (edge == null or focusOrderBefore(candidate, candidate_tab_index, edge.?, edge_tab_index) == forward) { edge = candidate; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index dff1f52a6..b19068e24 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1153,6 +1153,41 @@ pub fn remove(self: *Element, frame: *Frame) void { frame.removeNode(parent, node, .{ .reconnect_to = null }); } +// The tabindex of a focusable area, or null when the element can't take focus +// at all. A negative value is still focusable, just skipped by sequential +// focus navigation. +// https://html.spec.whatwg.org/multipage/interaction.html#focusable-area +pub fn focusTabIndex(self: *Element) ?i32 { + if (self.isDisabled()) { + return null; + } + if (self.is(Html) == null) { + return null; + } + + if (self.getAttributeInterned("tabindex")) |attr| { + return Html.parseInteger(attr) orelse 0; + } + + return switch (self.getTag()) { + .button, .select, .textarea, .iframe => 0, + .input => if (self.as(Html.Input)._input_type != .hidden) 0 else null, + .anchor, .area => if (self.getAttributeInterned("href") != null) 0 else null, + else => null, + }; +} + +// A focusable area that can take focus right now: connected and being rendered. +pub fn isFocusable(self: *Element, frame: *Frame, comptime access: StyleManager.InlineAccess) bool { + if (self.focusTabIndex() == null) { + return false; + } + if (self.asNode().isConnected() == false) { + return false; + } + return self.isVisible(frame, access); +} + pub fn focus(self: *Element, frame: *Frame) !void { if (self.asNode().isConnected() == false) { // a disconnected node cannot take focus diff --git a/src/server/cdp/domains/dom.zig b/src/server/cdp/domains/dom.zig index 9f11518cd..96bb85af5 100644 --- a/src/server/cdp/domains/dom.zig +++ b/src/server/cdp/domains/dom.zig @@ -696,6 +696,9 @@ fn focus(cmd: *CDP.Command) !void { const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId); const element = node.dom.is(DOMNode.Element) orelse return error.NodeIsNotAnElement; + if (element.isFocusable(frame, .scan) == false) { + return cmd.sendError(-32000, "Element is not focusable", .{}); + } try element.focus(frame); return cmd.sendResult(null, .{}); @@ -908,6 +911,47 @@ test "cdp.dom: focus makes the node activeElement and routes key events to it" { try testing.expect(result.isTrue()); } +test "cdp.dom: focus errors on an element that can't take focus" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "mcp_actions.html" }); + const frame = bc.mainFrame().?; + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + // #btn not rendered, #inp disabled, #hoverTarget a plain
. + _ = try ls.local.compileAndRun( + \\document.getElementById('btn').style.display = 'none'; + \\document.getElementById('inp').disabled = true; + , null); + + try ctx.processMessage(.{ .id = 1, .method = "DOM.performSearch", .params = .{ .query = "#btn, #inp, #hoverTarget" } }); + try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 3 }, .{ .id = 1 }); + try ctx.processMessage(.{ + .id = 2, + .method = "DOM.getSearchResults", + .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 3 }, + }); + try ctx.expectSentResult(.{ .nodeIds = &.{ 1, 2, 3 } }, .{ .id = 2 }); + + try ctx.processMessage(.{ .id = 3, .method = "DOM.focus", .params = .{ .nodeId = 1 } }); + try ctx.expectSentError(-32000, "Element is not focusable", .{ .id = 3 }); + try ctx.processMessage(.{ .id = 4, .method = "DOM.focus", .params = .{ .nodeId = 2 } }); + try ctx.expectSentError(-32000, "Element is not focusable", .{ .id = 4 }); + try ctx.processMessage(.{ .id = 5, .method = "DOM.focus", .params = .{ .nodeId = 3 } }); + try ctx.expectSentError(-32000, "Element is not focusable", .{ .id = 5 }); + + const result = try ls.local.compileAndRun("document.activeElement === document.body", null); + try testing.expect(result.isTrue()); +} + test "cdp.dom: setFileInputFiles on file input" { var ctx = try testing.context(); defer ctx.deinit();