diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 493afa689..b3b70dc95 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -590,3 +590,32 @@ testing.expectEqual(null, check.selectionEnd); } + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 5580069e0..418848f5b 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -173,7 +173,7 @@ pub fn setValue(self: *Input, value: []const u8, frame: *Frame) !void { } // move the text entry cursor position to the end of the text control - if (changed and self.selectionAvailable()) { + if (changed and self.tracksSelection()) { self._selection_start = @intCast(sanitized.len); self._selection_end = @intCast(sanitized.len); self._selection_direction = .none; @@ -628,8 +628,10 @@ const entry = text_entry.TextEntry(Input); pub const select = entry.select; pub const innerInsert = entry.innerInsert; pub const acceptsTextEntry = entry.acceptsTextEntry; +const tracksSelection = entry.tracksSelection; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; +pub const caretToEnd = entry.caretToEnd; pub const CaretMove = entry.CaretMove; pub const getSelectionDirection = entry.getSelectionDirection; pub const setSelectionStart = entry.setSelectionStart; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index a4aa87934..42431e682 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -143,6 +143,7 @@ pub const innerInsert = entry.innerInsert; pub const acceptsTextEntry = entry.acceptsTextEntry; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; +pub const caretToEnd = entry.caretToEnd; pub const CaretMove = entry.CaretMove; pub const getSelectionDirection = entry.getSelectionDirection; pub const setSelectionStart = entry.setSelectionStart; diff --git a/src/browser/webapi/element/text_entry.zig b/src/browser/webapi/element/text_entry.zig index ca68facd6..72f466880 100644 --- a/src/browser/webapi/element/text_entry.zig +++ b/src/browser/webapi/element/text_entry.zig @@ -41,9 +41,30 @@ pub fn TextEntry(comptime T: type) type { }; } + /// Whether the control keeps a text selection/caret internally. Wider + /// than selectionAvailable(): email and number have selectable text + /// (and a caret) even though the selection APIs don't apply to them. + pub fn tracksSelection(self: *const T) bool { + if (self.selectionAvailable()) { + return true; + } + if (!@hasField(T, "_input_type")) { + return false; + } + return switch (self._input_type) { + .email, .number => true, + else => false, + }; + } + + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea/input-select + // Never throws: without selectable text it just returns. pub fn select(self: *T, frame: *Frame) !void { + if (tracksSelection(self) == false) { + return; + } const len: u32 = @intCast(self.getValue().len); - try setSelectionRange(self, 0, len, null, frame); + try applySelectionRange(self, 0, len, .none, frame); const event = try Event.init("select", .{ .bubbles = true }, frame.page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -56,8 +77,10 @@ pub fn TextEntry(comptime T: type) type { // fully selected, replace the content. const new_value = try arena.dupe(u8, str); try self.setUserValue(new_value, frame); - self._selection_start = @intCast(new_value.len); - self._selection_end = @intCast(new_value.len); + // the sanitized value can be shorter than what was inserted + const new_len: u32 = @intCast(self.getValue().len); + self._selection_start = new_len; + self._selection_end = new_len; self._selection_direction = .none; try dispatchSelectionChangeEvent(self, frame); }, @@ -74,20 +97,20 @@ pub fn TextEntry(comptime T: type) type { ); try self.setUserValue(new_value, frame); - const new_pos = range[0] + str.len; - self._selection_start = @intCast(new_pos); - self._selection_end = @intCast(new_pos); + const new_pos: u32 = @intCast(@min(range[0] + str.len, self.getValue().len)); + self._selection_start = new_pos; + self._selection_end = new_pos; self._selection_direction = .none; try dispatchSelectionChangeEvent(self, frame); }, .none => { // nothing selected, insert at the caret. Controls without - // selection support keep no caret; append. + // a caret (e.g. date) append. const current_value = self.getValue(); - const caret = if (self.selectionAvailable()) @min(self._selection_start, current_value.len) else current_value.len; + const caret = if (tracksSelection(self)) @min(self._selection_start, current_value.len) else current_value.len; const new_value = try std.mem.concat(arena, u8, &.{ current_value[0..caret], str, current_value[caret..] }); try self.setUserValue(new_value, frame); - if (self.selectionAvailable()) { + if (tracksSelection(self)) { // the sanitized value can be shorter than what was inserted const new_pos: u32 = @intCast(@min(caret + str.len, self.getValue().len)); self._selection_start = new_pos; @@ -117,8 +140,8 @@ pub fn TextEntry(comptime T: type) type { start, end = range; }, .none => { - // Controls without selection support keep no caret; edit at the end. - const caret = if (self.selectionAvailable()) @min(self._selection_start, value_len) else value_len; + // Controls without a caret (e.g. date) edit at the end. + const caret = if (tracksSelection(self)) @min(self._selection_start, value_len) else value_len; if (forward) { if (caret >= value_len) { return; @@ -150,6 +173,16 @@ pub fn TextEntry(comptime T: type) type { try dispatchInputEvent(self, null, if (forward) "deleteContentForward" else "deleteContentBackward", frame); } + // Collapses the selection to the end of the value. Unlike + // setSelectionRange(), applies to email and number too. + pub fn caretToEnd(self: *T, frame: *Frame) !void { + if (tracksSelection(self) == false) { + return; + } + const len: u32 = @intCast(self.getValue().len); + try applySelectionRange(self, len, len, .none, frame); + } + pub const CaretMove = enum { backward, forward, line_start, line_end }; // Default action of the caret movement keys (ArrowLeft, ArrowRight, @@ -158,7 +191,7 @@ pub fn TextEntry(comptime T: type) type { // ArrowLeft/ArrowRight on a non-collapsed selection collapses it to // the corresponding edge without moving, like Chrome. pub fn moveCaret(self: *T, move: CaretMove, extend: bool, frame: *Frame) !void { - if (self.selectionAvailable() == false) { + if (tracksSelection(self) == false) { return; } const value = self.getValue(); @@ -266,6 +299,16 @@ pub fn TextEntry(comptime T: type) type { } else break :blk .none; }; + return applySelectionRange(self, selection_start, selection_end, direction, frame); + } + + fn applySelectionRange( + self: *T, + selection_start: u32, + selection_end: u32, + direction: Selection.SelectionDirection, + frame: *Frame, + ) !void { const len_u32: u32 = @intCast(self.getValue().len); var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; @@ -285,7 +328,7 @@ pub fn TextEntry(comptime T: type) type { const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; fn howSelected(self: *const T) HowSelected { - if (self.selectionAvailable() == false) { + if (tracksSelection(self) == false) { return .none; } const value_len: u32 = @intCast(self.getValue().len); diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig index 9a00fe4ae..a59e80921 100644 --- a/src/server/bidi/http_command.zig +++ b/src/server/bidi/http_command.zig @@ -689,9 +689,9 @@ fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { return cmd.sendError("element not interactable", "element can't be focused"); } if (element.is(Html.Input)) |input_element| { - try caretToEnd(input_element, frame); + try input_element.caretToEnd(frame); } else if (element.is(Html.TextArea)) |textarea| { - try caretToEnd(textarea, frame); + try textarea.caretToEnd(frame); } } @@ -702,14 +702,6 @@ fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { return browsing_context.answerAfterNavigation(cmd, ctx, frame); } -fn caretToEnd(ctl: anytype, frame: *Frame) !void { - if (ctl.selectionAvailable() == false) { - return; - } - const len: u32 = @intCast(ctl.getValue().len); - try ctl.setSelectionRange(len, len, null, frame); -} - fn requireInteractable(cmd: *BiDi.Command, element: *Node.Element, frame: *Frame) !bool { element.scrollIntoView(null, frame); if (element.checkVisibility(.{}, frame)) { diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 5dcdfb190..c590d7649 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -200,6 +200,42 @@ test "cdp.input: insertText is a user edit for tooLong" { try testing.expect((try ls.local.compileAndRun("inp.validity.tooLong === false", null)).isTrue()); } +test "cdp.input: insertText replaces select()ed value of email and number inputs" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + + try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ .reason = .address_bar, .kind = .{ .push = null } }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + // What Playwright's fill() does: select() then Input.insertText. + _ = try ls.local.compileAndRun( + \\const inp = document.getElementById('inp'); + \\inp.type = 'email'; + \\inp.value = 'old@example.com'; + \\inp.select(); + \\inp.focus(); + , null); + try ctx.processMessage(.{ .id = 1, .method = "Input.insertText", .params = .{ .text = "new@example.com" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'new@example.com'", null)).isTrue()); + + _ = try ls.local.compileAndRun("inp.type = 'number'; inp.value = '12'; inp.select();", null); + try ctx.processMessage(.{ .id = 2, .method = "Input.insertText", .params = .{ .text = "345" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === '345'", null)).isTrue()); + + // The caret lands at the end of the sanitized value, not of the inserted text. + _ = try ls.local.compileAndRun("inp.type = 'text'; inp.value = 'ab'; inp.select();", null); + try ctx.processMessage(.{ .id = 3, .method = "Input.insertText", .params = .{ .text = "c\nd" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'cd' && inp.selectionStart === 2", null)).isTrue()); +} + test "cdp.input: dispatchMouseEvent mouseMoved fires hover events" { var ctx = try testing.context(); defer ctx.deinit();