dom.focus only on focusable elements

This commit is contained in:
Karl Seguin committed 2026-09-11 06:02:27 +08:00
1 parent 9f4f31820f
commit 35e13e4d64
3 files changed
+81 -29

No files matched your search

+2 -29
View File
@@ -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;
+35
View File
@@ -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
+44
View File
@@ -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 <div>.
_ = 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();