From d7015fa3b68ef54ecdedb5842e008a34f90c623d Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 19 Jan 2026 18:34:02 -0800 Subject: [PATCH] add selection api to HTMLInputElement --- src/browser/Page.zig | 24 +--- src/browser/webapi/Selection.zig | 2 +- src/browser/webapi/element/html/Input.zig | 131 +++++++++++++++++++++- 3 files changed, 130 insertions(+), 27 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 841d4156..83df1085 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -2933,16 +2933,7 @@ pub fn handleKeydown(self: *Page, target: *Node, event: *Event) !void { // Handle printable characters if (key.isPrintable()) { - // if the input is selected, replace the content. - if (input._selected) { - const new_value = try self.arena.dupe(u8, key.asString()); - try input.setValue(new_value, self); - input._selected = false; - return; - } - const current_value = input.getValue(); - const new_value = try std.mem.concat(self.arena, u8, &.{ current_value, key.asString() }); - try input.setValue(new_value, self); + try input.innerInsert(key.asString(), self); } return; } @@ -3011,18 +3002,7 @@ pub fn insertText(self: *Page, v: []const u8) !void { return; } - // If the input is selected, replace the existing value - if (input._selected) { - const new_value = try self.arena.dupe(u8, v); - try input.setValue(new_value, self); - input._selected = false; - return; - } - - // Or append the value - const current_value = input.getValue(); - const new_value = try std.mem.concat(self.arena, u8, &.{ current_value, v }); - return input.setValue(new_value, self); + try input.innerInsert(v, self); } if (html_element.is(Element.Html.TextArea)) |textarea| { diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index f2c32e22..69054b17 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -28,7 +28,7 @@ const Node = @import("Node.zig"); /// https://w3c.github.io/selection-api/ const Selection = @This(); -const SelectionDirection = enum { backward, forward, none }; +pub const SelectionDirection = enum { backward, forward, none }; _range: ?*Range = null, _direction: SelectionDirection = .none, diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 5c04b6a4..b7bb14f2 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -24,6 +24,7 @@ const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); +const Selection = @import("../../Selection.zig"); const Input = @This(); @@ -74,9 +75,12 @@ _value: ?[]const u8 = null, _checked: bool = false, _checked_dirty: bool = false, _input_type: Type = .text, -_selected: bool = false, _indeterminate: bool = false, +_selection_start: u32 = 0, +_selection_end: u32 = 0, +_selection_direction: Selection.SelectionDirection = .none, + pub fn asElement(self: *Input) *Element { return self._proto._proto; } @@ -255,8 +259,120 @@ pub fn setRequired(self: *Input, required: bool, page: *Page) !void { } } -pub fn select(self: *Input) void { - self._selected = true; +pub fn select(self: *Input) !void { + const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; + try self.setSelectionRange(0, len, null); +} + +fn selectionAvailable(self: *const Input) bool { + switch (self._input_type) { + .text, .search, .url, .tel, .password => return true, + else => return false, + } +} + +const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; + +fn howSelected(self: *const Input) HowSelected { + if (!self.selectionAvailable()) return .none; + const value = self._value orelse return .none; + + if (self._selection_start == self._selection_end) return .none; + if (self._selection_start == 0 and self._selection_end == value.len) return .full; + return .{ .partial = .{ self._selection_start, self._selection_end } }; +} + +pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void { + const arena = page.arena; + + switch (self.howSelected()) { + .full => { + // if the input is fully selected, replace the content. + const new_value = try arena.dupe(u8, str); + try self.setValue(new_value, page); + self._selection_start = 1; + self._selection_end = 1; + self._selection_direction = .none; + }, + .partial => |range| { + // if the input is partially selected, replace the selected content. + const current_value = self.getValue(); + const before = current_value[0..range[0]]; + const remaining = current_value[range[1]..]; + + const new_value = try std.mem.concat( + arena, + u8, + &.{ before, str, remaining }, + ); + try self.setValue(new_value, page); + + const new_pos = range[0] + str.len; + self._selection_start = @intCast(new_pos); + self._selection_end = @intCast(new_pos); + self._selection_direction = .none; + }, + .none => { + // if the input is not selected, just insert at cursor. + const current_value = self.getValue(); + const new_value = try std.mem.concat(arena, u8, &.{ current_value, str }); + try self.setValue(new_value, page); + }, + } +} + +pub fn getSelectionDirection(self: *const Input) []const u8 { + return @tagName(self._selection_direction); +} + +pub fn getSelectionStart(self: *const Input) !?u32 { + if (!self.selectionAvailable()) return null; + return self._selection_start; +} + +pub fn setSelectionStart(self: *Input, value: u32) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + self._selection_start = value; +} + +pub fn getSelectionEnd(self: *const Input) !?u32 { + if (!self.selectionAvailable()) return null; + return self._selection_end; +} + +pub fn setSelectionEnd(self: *Input, value: u32) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + self._selection_end = value; +} + +pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void { + if (!self.selectionAvailable()) return error.InvalidStateError; + + const direction = blk: { + if (selection_dir) |sd| { + break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; + } else break :blk .none; + }; + + const value = self._value orelse { + self._selection_start = 0; + self._selection_end = 0; + self._selection_direction = .none; + return; + }; + + const len_u32: u32 = @intCast(value.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; + + // If end is less than start, both are equal to end. + if (end < start) { + start = end; + } + + self._selection_direction = direction; + self._selection_start = start; + self._selection_end = end; } pub fn getForm(self: *Input, page: *Page) ?*Form { @@ -352,6 +468,11 @@ pub const JsApi = struct { pub const form = bridge.accessor(Input.getForm, null, .{}); pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{}); pub const select = bridge.function(Input.select, .{}); + + pub const selectionStart = bridge.accessor(Input.getSelectionStart, Input.setSelectionStart, .{}); + pub const selectionEnd = bridge.accessor(Input.getSelectionEnd, Input.setSelectionEnd, .{}); + pub const selectionDirection = bridge.accessor(Input.getSelectionDirection, null, .{}); + pub const setSelectionRange = bridge.function(Input.setSelectionRange, .{ .dom_exception = true }); }; pub const Build = struct { @@ -422,7 +543,9 @@ pub const Build = struct { clone._value = source._value; clone._checked = source._checked; clone._checked_dirty = source._checked_dirty; - clone._selected = source._selected; + clone._selection_direction = source._selection_direction; + clone._selection_start = source._selection_start; + clone._selection_end = source._selection_end; clone._indeterminate = source._indeterminate; } };