input: one key target resolver and shared activation predicates

Every key path resolves its target through user_input.focusedElement,
which is document.activeElement with the <body> fallback. WebDriver and
the MCP press action used to fall back to the document node instead, a
target no default action or char step handles.

The Enter and Space activation rules share isButton, and the text-entry
rule (no text goes into a checkbox or radio) lives on the TextEntry
mixin as acceptsTextEntry rather than as a type test inside the shared
insertion helper. pressKey takes its extra ref only when a keypress will
be built from the keydown.
This commit is contained in:
Adrià Arrufat committed 2026-09-17 14:09:12 +02:00
1 parent 7b30a7447e
commit 21c2d71e84
8 files changed
+100 -89

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ const Viewport = @This();
width: u32,
height: u32,
scale: f32 = 1.0, // for screenshot raster and window.devicePixelRatio
scale: f32 = 1.0, // CSS px to device px
// window.screen dimensions; null means the same as the viewport.
screen_width: ?u32 = null,
screen_height: ?u32 = null,
+4 -8
View File
@@ -129,11 +129,10 @@ pub fn hover(node: *DOMNode, frame: *Frame) !void {
}
pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
const target_el: ?*Element = if (node) |n|
const target: *Element = if (node) |n|
(n.is(Element) orelse return error.InvalidNodeType)
else
null;
const target = if (target_el) |el| el.asEventTarget() else frame.document.asNode().asEventTarget();
Frame.user_input.focusedElement(frame) orelse return error.ActionFailed;
const canonical = canonicalKey(key);
const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{
@@ -143,10 +142,7 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
.key = canonical,
}, frame);
_ = (if (target_el) |el|
Frame.user_input.pressKey(frame, el, keydown_event, Frame.user_input.textForKey(keydown_event))
else
frame._event_manager.dispatchCancelable(target, keydown_event.asEvent())) catch |err| {
_ = Frame.user_input.pressKey(frame, target, keydown_event, Frame.user_input.textForKey(keydown_event)) catch |err| {
lp.log.err(.app, "press keydown failed", .{ .err = err });
return error.ActionFailed;
};
@@ -158,7 +154,7 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void {
.key = canonical,
}, frame);
frame._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| {
frame._event_manager.dispatch(target.asEventTarget(), keyup_event.asEvent()) catch |err| {
lp.log.err(.app, "press keyup failed", .{ .err = err });
return error.ActionFailed;
};
+41 -49
View File
@@ -624,33 +624,26 @@ fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8,
}, .{ .anchor = target_frame });
}
/// pressKey on the focused element.
pub fn triggerKeyDown(frame: *Frame, keydown: *KeyboardEvent, text: ?[]const u8) !bool {
const element = focusedElement(frame, keydown) orelse return false;
const element = focusedElement(frame) orelse {
keydown.asEvent().deinit(frame.page);
return false;
};
return pressKey(frame, element, keydown, text);
}
pub fn triggerKeyUp(frame: *Frame, keyup: *KeyboardEvent) !void {
const element = focusedElement(frame, keyup) orelse return;
const element = focusedElement(frame) orelse {
keyup.asEvent().deinit(frame.page);
return;
};
try frame._event_manager.dispatch(element.asEventTarget(), keyup.asEvent());
}
// `document.activeElement`, so with nothing focused a key still fires on
// <body> and Tab's focus navigation can run.
fn focusedElement(frame: *Frame, keyboard_event: *KeyboardEvent) ?*Element {
const element = frame.window._document.getActiveElement() orelse {
keyboard_event.asEvent().deinit(frame.page);
return null;
};
if (comptime lp.IS_DEBUG) {
log.debug(.frame, "frame key", .{
.url = frame.url,
.node = element,
.key = keyboard_event._key,
.type = frame._type,
});
}
return element;
/// Where a key event goes: `document.activeElement`, so with nothing focused
/// a key still fires on <body> and Tab's focus navigation can run.
pub fn focusedElement(frame: *Frame) ?*Element {
return frame.window._document.getActiveElement();
}
/// Dispatches a trusted keydown on `target` then, unless cancelled, types
@@ -658,15 +651,23 @@ fn focusedElement(frame: *Frame, keyboard_event: *KeyboardEvent) ?*Element {
/// char as its own event, as chromedp does). Returns whether the keydown was
/// cancelled.
pub fn pressKey(frame: *Frame, target: *Element, keydown: *KeyboardEvent, text: ?[]const u8) !bool {
if (comptime lp.IS_DEBUG) {
log.debug(.frame, "frame keydown", .{
.url = frame.url,
.node = target,
.key = keydown._key,
.type = frame._type,
});
}
const event = keydown.asEvent();
const t = text orelse return frame._event_manager.dispatchCancelable(target.asEventTarget(), event);
// dispatch drops the event; keypressFor still needs it.
event.acquireRef();
defer event.releaseRef(frame.page);
if (try frame._event_manager.dispatchCancelable(target.asEventTarget(), event)) {
return true;
}
const t = text orelse return false;
// logged like a default action's failure, not the key event's
typeChar(frame, target, try keypressFor(frame, keydown), t) catch |err| {
log.warn(.frame, "frame.keypress", .{ .err = err });
@@ -696,7 +697,7 @@ pub fn typeChar(frame: *Frame, target: *Element, keypress: *KeyboardEvent, text:
return;
}
const is_enter = text.len == 1 and (text[0] == '\r' or text[0] == '\n');
if (is_enter and enterClicks(target)) {
if (is_enter and isButton(target)) {
return dispatchKeyboardClick(frame, target);
}
@@ -718,7 +719,6 @@ pub fn typeChar(frame: *Frame, target: *Element, keypress: *KeyboardEvent, text:
}
}
/// The keypress mirroring `keydown`'s key and modifiers.
fn keypressFor(frame: *Frame, keydown: *const KeyboardEvent) !*KeyboardEvent {
return KeyboardEvent.initTrusted(comptime .wrap("keypress"), .{
.key = keydown.getKey().asString(),
@@ -743,22 +743,17 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void {
}
if (key == .Enter and event.getIsTrusted()) {
// A link follows Enter on the keydown; buttons wait for the keypress,
// see typeChar.
if (target.is(Element.Html.Anchor)) |anchor| {
if (anchor.asElement().getAttributeInterned("href") != null) {
return dispatchKeyboardClick(frame, anchor.asElement());
if (target.is(Element)) |element| {
if (enterFollowsLink(element)) {
return dispatchKeyboardClick(frame, element);
}
}
}
if (target.is(Element.Html.Input)) |input| {
// Don't handle text input for radio/checkbox
const input_type = input._input_type;
if (input_type == .radio or input_type == .checkbox) {
if (!input.acceptsTextEntry()) {
return;
}
return editKey(frame, keyboard_event, input, key);
}
@@ -787,11 +782,8 @@ fn editKey(frame: *Frame, keyboard_event: *KeyboardEvent, ctl: anytype, key: Key
}
fn insertInto(frame: *Frame, ctl: anytype, text: []const u8) !void {
if (@TypeOf(ctl) == *Element.Html.Input) {
const input_type = ctl._input_type;
if (input_type == .radio or input_type == .checkbox) {
return;
}
if (!ctl.acceptsTextEntry()) {
return;
}
if (try allowEdit(frame, ctl.asElement(), text, text, "insertText")) {
try ctl.innerInsert(text, frame);
@@ -881,8 +873,15 @@ fn dispatchKeyboardClick(frame: *Frame, element: *Element) !void {
try frame._event_manager.dispatch(element.asEventTarget(), event.asEvent());
}
// elements where Enter's keypress dispatches a click
fn enterClicks(element: *Element) bool {
// Which elements act on which key, and at which step: a link follows Enter on
// the keydown, a button clicks on Enter's keypress (typeChar) and on Space's
// keyup, as do checkboxes and radios for Space.
fn enterFollowsLink(element: *Element) bool {
const html_element = element.is(Element.Html) orelse return false;
return html_element._type == .anchor and element.getAttributeInterned("href") != null;
}
fn isButton(element: *Element) bool {
const html_element = element.is(Element.Html) orelse return false;
if (html_element._type == .button) {
return true;
@@ -896,19 +895,12 @@ fn enterClicks(element: *Element) bool {
return false;
}
// elements where space on a keyup should dispatch a click-click event
fn spaceActivates(element: *Element) bool {
const html_element = element.is(Element.Html) orelse return false;
if (html_element._type == .button) {
if (isButton(element)) {
return true;
}
if (element.is(Element.Html.Input)) |input| {
return switch (input._input_type) {
.button, .submit, .reset, .image, .checkbox, .radio => true,
else => false,
};
}
return false;
const input = element.is(Element.Html.Input) orelse return false;
return input._input_type == .checkbox or input._input_type == .radio;
}
// Sequential focus navigation: move `document.activeElement` to the next (Tab)
+8 -10
View File
@@ -423,10 +423,7 @@ fn performKeySource(source: js.Object, frame: *Frame) !void {
// longer does.
setModifier(&frame.page.input_modifiers, key, is_down);
// Key actions have no explicit target; they go to the focused element,
// or the document if nothing is focused. Resolved per action since a
// key's default action can move focus.
dispatchKey(frame.document._active_element, is_down, key, frame);
dispatchKey(is_down, key, frame);
}
}
@@ -511,7 +508,9 @@ fn setModifier(modifiers: *Modifiers, key: []const u8, pressed: bool) void {
}
}
fn dispatchKey(focused: ?*Element, is_down: bool, key: []const u8, frame: *Frame) void {
// Key actions have no explicit target; they go to the focused element,
// resolved per action since a key's default action can move focus.
fn dispatchKey(is_down: bool, key: []const u8, frame: *Frame) void {
const typ: lp.String = if (is_down) comptime .wrap("keydown") else comptime .wrap("keyup");
const modifiers = frame.page.input_modifiers;
const event = KeyboardEvent.initTrusted(typ, .{
@@ -527,11 +526,10 @@ fn dispatchKey(focused: ?*Element, is_down: bool, key: []const u8, frame: *Frame
log.warn(.app, "webdriver key event", .{ .err = err });
return;
};
const el = focused orelse return dispatch(frame.document.asNode().asEventTarget(), event.asEvent(), frame, typ.str());
if (!is_down) {
return dispatch(el.asEventTarget(), event.asEvent(), frame, typ.str());
}
_ = Frame.user_input.pressKey(frame, el, event, Frame.user_input.textForKey(event)) catch |err| {
(if (is_down)
Frame.user_input.triggerKeyDown(frame, event, Frame.user_input.textForKey(event))
else
Frame.user_input.triggerKeyUp(frame, event)) catch |err| {
log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ.str() });
};
}
@@ -627,6 +627,7 @@ const entry = text_entry.TextEntry(Input);
pub const select = entry.select;
pub const innerInsert = entry.innerInsert;
pub const acceptsTextEntry = entry.acceptsTextEntry;
pub const innerDelete = entry.innerDelete;
pub const moveCaret = entry.moveCaret;
pub const CaretMove = entry.CaretMove;
@@ -140,6 +140,7 @@ const entry = text_entry.TextEntry(TextArea);
pub const select = entry.select;
pub const innerInsert = entry.innerInsert;
pub const acceptsTextEntry = entry.acceptsTextEntry;
pub const innerDelete = entry.innerDelete;
pub const moveCaret = entry.moveCaret;
pub const CaretMove = entry.CaretMove;
+12
View File
@@ -29,6 +29,18 @@ const InputEvent = @import("../event/InputEvent.zig");
pub fn TextEntry(comptime T: type) type {
return struct {
/// Whether typing edits the control's value. Checkbox and radio share
/// Input's value machinery but no text goes into them.
pub fn acceptsTextEntry(self: *const T) bool {
if (!@hasField(T, "_input_type")) {
return true;
}
return switch (self._input_type) {
.checkbox, .radio => false,
else => true,
};
}
pub fn select(self: *T, frame: *Frame) !void {
const len: u32 = @intCast(self.getValue().len);
try setSelectionRange(self, 0, len, null, frame);
+32 -21
View File
@@ -56,6 +56,9 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void {
try cmd.sendResult(null, .{});
// rawKeyDown is a Chrome-internal event type not used for JS dispatch
if (params.type == .rawKeyDown) return;
const bc = cmd.browser_context orelse return;
const frame = bc.mainFrame() orelse return;
@@ -73,21 +76,23 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void {
};
switch (params.type) {
// rawKeyDown is a Chrome-internal event type not used for JS dispatch
.rawKeyDown => {},
.rawKeyDown => unreachable,
.keyDown => {
const event = try KeyboardEvent.initTrusted(comptime .wrap("keydown"), opts, frame);
const prevented = try Frame.user_input.triggerKeyDown(frame, event, text);
bc.suppress_next_char = prevented and text == null;
},
.keyUp => try Frame.user_input.triggerKeyUp(frame, try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame)),
.keyUp => {
const event = try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame);
try Frame.user_input.triggerKeyUp(frame, event);
},
.char => {
const t = text orelse return;
if (bc.suppress_next_char) {
bc.suppress_next_char = false;
return;
}
const target = frame.window._document.getActiveElement() orelse return;
const target = Frame.user_input.focusedElement(frame) orelse return;
const event = try KeyboardEvent.initTrusted(comptime .wrap("keypress"), opts, frame);
try Frame.user_input.typeChar(frame, target, event, t);
},
@@ -948,6 +953,27 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
frame.js.localScope(&ls);
defer ls.deinit();
_ = try ls.local.compileAndRun(
\\document.body.insertAdjacentHTML('beforeend', '<form id=f>' +
\\ '<input id=text><input id=check type=checkbox><input id=submit type=submit>' +
\\ '<button id=button>go</button><input id=ibutton type=button><input id=reset type=reset></form>');
\\const form = document.getElementById('f');
\\form.addEventListener('submit', (e) => {
\\ e.preventDefault();
\\ window.events.push('submit');
\\});
\\// Only the focused control's keypress and click are recorded.
\\for (const t of ['keypress', 'click']) {
\\ form.addEventListener(t, (e) => {
\\ if (e.target === document.activeElement) window.events.push(t);
\\ }, true);
\\}
\\window.arm = (id) => {
\\ window.events = [];
\\ document.getElementById(id).focus();
\\};
, null);
const cases = [_]struct { id: []const u8, expect: []const u8 }{
.{ .id = "text", .expect = "keypress submit" },
.{ .id = "check", .expect = "keypress submit" },
@@ -959,21 +985,8 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
var id: u32 = 1;
for (cases) |c| {
var buf: [1024]u8 = undefined;
_ = try ls.local.compileAndRun(try std.fmt.bufPrint(&buf,
\\document.body.insertAdjacentHTML('beforeend', '<form id=f>' +
\\ '<input id=text><input id=check type=checkbox><input id=submit type=submit>' +
\\ '<button id=button>go</button><input id=ibutton type=button><input id=reset type=reset></form>');
\\window.events = [];
\\document.getElementById('f').addEventListener('submit', (e) => {{
\\ e.preventDefault();
\\ window.events.push('submit');
\\}});
\\var el = document.getElementById('{s}');
\\el.addEventListener('keypress', () => window.events.push('keypress'));
\\el.addEventListener('click', () => window.events.push('click'));
\\el.focus();
, .{c.id}), null);
var buf: [32]u8 = undefined;
_ = try ls.local.compileAndRun(try std.fmt.bufPrint(&buf, "arm('{s}')", .{c.id}), null);
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "Enter", .code = "Enter" } });
try ctx.expectSentResult(null, .{ .id = id });
@@ -987,7 +1000,5 @@ test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits once" {
const got = try (try ls.local.compileAndRun("window.events.join(' ')", null)).toStringSlice();
try testing.expectEqualSlices(u8, c.expect, got);
_ = try ls.local.compileAndRun("document.getElementById('f').remove()", null);
}
}