mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 20:32:00 -04:00
Merge pull request #3657 from onnimonni/fix/input-select-email
webapi: input select() must not throw for email/number
This commit is contained in:
6 files changed
+127
-24
No files matched your search
@@ -590,3 +590,32 @@
|
||||
testing.expectEqual(null, check.selectionEnd);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="select_without_selection_api">
|
||||
{
|
||||
// select() never throws, even where the selection APIs don't apply.
|
||||
for (const type of ['email', 'number', 'date', 'color', 'checkbox', 'radio', 'file', 'hidden']) {
|
||||
const input = document.createElement('input');
|
||||
input.type = type;
|
||||
input.select();
|
||||
testing.expectEqual(null, input.selectionStart);
|
||||
}
|
||||
|
||||
const email = document.createElement('input');
|
||||
email.type = 'email';
|
||||
email.value = 'old@example.com';
|
||||
let selects = 0;
|
||||
email.addEventListener('select', () => selects++);
|
||||
email.select();
|
||||
testing.expectEqual(1, selects);
|
||||
testing.expectError('InvalidStateError', () => email.setSelectionRange(0, 1));
|
||||
|
||||
const date = document.createElement('input');
|
||||
date.type = 'date';
|
||||
date.value = '2026-09-28';
|
||||
let date_selects = 0;
|
||||
date.addEventListener('select', () => date_selects++);
|
||||
date.select();
|
||||
testing.expectEqual(0, date_selects);
|
||||
}
|
||||
</script>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user