From 5e32ccbf1283919e473b129f2bae6c99a0c6149c Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:27:34 -0800 Subject: [PATCH 01/11] add selectionchange handler on Document --- src/browser/webapi/Document.zig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 30a65e41..c739057f 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -56,6 +56,20 @@ _script_created_parser: ?Parser.Streaming = null, _adopted_style_sheets: ?js.Object.Global = null, _selection: Selection = .init, +_on_selectionchange: ?js.Function.Global = null, + +pub fn getOnSelectionChange(self: *Document) ?js.Function.Global { + return self._on_selectionchange; +} + +pub fn setOnSelectionChange(self: *Document, listener: ?js.Function) !void { + if (listener) |listen| { + self._on_selectionchange = try listen.persistWithThis(self); + } else { + self._on_selectionchange = null; + } +} + pub const Type = union(enum) { generic, html: *HTMLDocument, @@ -930,6 +944,7 @@ pub const JsApi = struct { }); } + pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{}); pub const URL = bridge.accessor(Document.getURL, null, .{}); pub const documentURI = bridge.accessor(Document.getURL, null, .{}); pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{}); From 7c8fcf73f64233f44be290cd086ac82e90e3ab92 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:27:50 -0800 Subject: [PATCH 02/11] dispatch selectionchange when Selection changes --- src/browser/webapi/Selection.zig | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig index 69054b17..a44e1940 100644 --- a/src/browser/webapi/Selection.zig +++ b/src/browser/webapi/Selection.zig @@ -24,6 +24,8 @@ const Page = @import("../Page.zig"); const Range = @import("Range.zig"); const AbstractRange = @import("AbstractRange.zig"); const Node = @import("Node.zig"); +const Event = @import("Event.zig"); +const Document = @import("Document.zig"); /// https://w3c.github.io/selection-api/ const Selection = @This(); @@ -35,6 +37,12 @@ _direction: SelectionDirection = .none, pub const init: Selection = .{}; +fn dispatchSelectionChangeEvent(page: *Page) !void { + const event = try Event.init("selectionchange", .{}, page); + defer if (!event._v8_handoff) event.deinit(false); + try page._event_manager.dispatch(page.document.asEventTarget(), event); +} + fn isInTree(self: *const Selection) bool { if (self._range == null) return false; const anchor_node = self.getAnchorNode() orelse return false; @@ -110,23 +118,26 @@ pub fn getType(self: *const Selection) []const u8 { return "Range"; } -pub fn addRange(self: *Selection, range: *Range) !void { +pub fn addRange(self: *Selection, range: *Range, page: *Page) !void { if (self._range != null) return; self._range = range; + try dispatchSelectionChangeEvent(page); } -pub fn removeRange(self: *Selection, range: *Range) !void { +pub fn removeRange(self: *Selection, range: *Range, page: *Page) !void { if (self._range == range) { self._range = null; + try dispatchSelectionChangeEvent(page); return; } else { return error.NotFound; } } -pub fn removeAllRanges(self: *Selection) void { +pub fn removeAllRanges(self: *Selection, page: *Page) !void { self._range = null; self._direction = .none; + try dispatchSelectionChangeEvent(page); } pub fn collapseToEnd(self: *Selection, page: *Page) !void { @@ -142,6 +153,7 @@ pub fn collapseToEnd(self: *Selection, page: *Page) !void { self._range = new_range; self._direction = .none; + try dispatchSelectionChangeEvent(page); } pub fn collapseToStart(self: *Selection, page: *Page) !void { @@ -157,6 +169,7 @@ pub fn collapseToStart(self: *Selection, page: *Page) !void { self._range = new_range; self._direction = .none; + try dispatchSelectionChangeEvent(page); } pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool { @@ -187,8 +200,8 @@ pub fn containsNode(self: *const Selection, node: *Node, partial: bool) !bool { pub fn deleteFromDocument(self: *Selection, page: *Page) !void { const range = self._range orelse return; - try range.deleteContents(page); + try dispatchSelectionChangeEvent(page); } pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void { @@ -230,6 +243,7 @@ pub fn extend(self: *Selection, node: *Node, _offset: ?u32, page: *Page) !void { } self._range = new_range; + try dispatchSelectionChangeEvent(page); } pub fn getRangeAt(self: *Selection, index: u32) !*Range { @@ -309,6 +323,7 @@ pub fn selectAllChildren(self: *Selection, parent: *Node, page: *Page) !void { self._range = range; self._direction = .forward; + try dispatchSelectionChangeEvent(page); } pub fn setBaseAndExtent( @@ -355,11 +370,12 @@ pub fn setBaseAndExtent( } self._range = range; + try dispatchSelectionChangeEvent(page); } pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !void { const node = _node orelse { - self.removeAllRanges(); + try self.removeAllRanges(page); return; }; @@ -376,6 +392,7 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, page: *Page) !vo self._range = range; self._direction = .none; + try dispatchSelectionChangeEvent(page); } pub fn toString(self: *const Selection, page: *Page) ![]const u8 { From 0d508a88f646a380713f6b39be9e9f9afe770367 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:32:36 -0800 Subject: [PATCH 03/11] add selectionchange tests --- src/browser/tests/selection.html | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/browser/tests/selection.html b/src/browser/tests/selection.html index b10dcb50..6b5a5d0c 100644 --- a/src/browser/tests/selection.html +++ b/src/browser/tests/selection.html @@ -541,3 +541,97 @@ testing.expectEqual(3, sel.focusOffset); } + + From ecb8f1de30c22df3c7f27c1ca0232a9b905afd72 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:38:33 -0800 Subject: [PATCH 04/11] add selectionchange to HTMLInputElement --- src/browser/webapi/element/html/Input.zig | 44 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index d6fe71c2..eafd44d9 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -27,6 +27,7 @@ const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Selection = @import("../../Selection.zig"); +const Event = @import("../../Event.zig"); const Input = @This(); @@ -83,6 +84,26 @@ _selection_start: u32 = 0, _selection_end: u32 = 0, _selection_direction: Selection.SelectionDirection = .none, +_on_selectionchange: ?js.Function.Global = null, + +pub fn getOnSelectionChange(self: *Input) ?js.Function.Global { + return self._on_selectionchange; +} + +pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { + if (listener) |listen| { + self._on_selectionchange = try listen.persistWithThis(self); + } else { + self._on_selectionchange = null; + } +} + +fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void { + const event = try Event.init("selectionchange", .{ .bubbles = true }, page); + defer if (!event._v8_handoff) event.deinit(false); + try page._event_manager.dispatch(self.asElement().asEventTarget(), event); +} + pub fn asElement(self: *Input) *Element { return self._proto._proto; } @@ -261,9 +282,9 @@ pub fn setRequired(self: *Input, required: bool, page: *Page) !void { } } -pub fn select(self: *Input) !void { +pub fn select(self: *Input, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; - try self.setSelectionRange(0, len, null); + try self.setSelectionRange(0, len, null, page); } fn selectionAvailable(self: *const Input) bool { @@ -295,6 +316,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void { self._selection_start = @intCast(new_value.len); self._selection_end = @intCast(new_value.len); self._selection_direction = .none; + try self.dispatchSelectionChangeEvent(page); }, .partial => |range| { // if the input is partially selected, replace the selected content. @@ -313,6 +335,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void { self._selection_start = @intCast(new_pos); self._selection_end = @intCast(new_pos); self._selection_direction = .none; + try self.dispatchSelectionChangeEvent(page); }, .none => { // if the input is not selected, just insert at cursor. @@ -332,9 +355,10 @@ pub fn getSelectionStart(self: *const Input) !?u32 { return self._selection_start; } -pub fn setSelectionStart(self: *Input, value: u32) !void { +pub fn setSelectionStart(self: *Input, value: u32, page: *Page) !void { if (!self.selectionAvailable()) return error.InvalidStateError; self._selection_start = value; + try self.dispatchSelectionChangeEvent(page); } pub fn getSelectionEnd(self: *const Input) !?u32 { @@ -342,12 +366,19 @@ pub fn getSelectionEnd(self: *const Input) !?u32 { return self._selection_end; } -pub fn setSelectionEnd(self: *Input, value: u32) !void { +pub fn setSelectionEnd(self: *Input, value: u32, page: *Page) !void { if (!self.selectionAvailable()) return error.InvalidStateError; self._selection_end = value; + try self.dispatchSelectionChangeEvent(page); } -pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void { +pub fn setSelectionRange( + self: *Input, + selection_start: u32, + selection_end: u32, + selection_dir: ?[]const u8, + page: *Page, +) !void { if (!self.selectionAvailable()) return error.InvalidStateError; const direction = blk: { @@ -375,6 +406,8 @@ pub fn setSelectionRange(self: *Input, selection_start: u32, selection_end: u32, self._selection_direction = direction; self._selection_start = start; self._selection_end = end; + + try self.dispatchSelectionChangeEvent(page); } pub fn getForm(self: *Input, page: *Page) ?*Form { @@ -453,6 +486,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const onselectionchange = bridge.accessor(Input.getOnSelectionChange, Input.setOnSelectionChange, .{}); pub const @"type" = bridge.accessor(Input.getType, Input.setType, .{}); pub const value = bridge.accessor(Input.getValue, Input.setValue, .{}); pub const defaultValue = bridge.accessor(Input.getDefaultValue, Input.setDefaultValue, .{}); From 4aec4ef80aa291b81ff825a7d9c7821a1c5112bd Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:42:35 -0800 Subject: [PATCH 05/11] add selectionchange to HTMLTextAreaElement --- src/browser/webapi/element/html/TextArea.zig | 44 +++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 31810832..d2c4d4fe 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -25,6 +25,7 @@ const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); const Form = @import("Form.zig"); const Selection = @import("../../Selection.zig"); +const Event = @import("../../Event.zig"); const TextArea = @This(); @@ -35,6 +36,26 @@ _selection_start: u32 = 0, _selection_end: u32 = 0, _selection_direction: Selection.SelectionDirection = .none, +_on_selectionchange: ?js.Function.Global = null, + +pub fn getOnSelectionChange(self: *TextArea) ?js.Function.Global { + return self._on_selectionchange; +} + +pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void { + if (listener) |listen| { + self._on_selectionchange = try listen.persistWithThis(self); + } else { + self._on_selectionchange = null; + } +} + +fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void { + const event = try Event.init("selectionchange", .{ .bubbles = true }, page); + defer if (!event._v8_handoff) event.deinit(false); + try page._event_manager.dispatch(self.asElement().asEventTarget(), event); +} + pub fn asElement(self: *TextArea) *Element { return self._proto._proto; } @@ -115,9 +136,9 @@ pub fn setRequired(self: *TextArea, required: bool, page: *Page) !void { } } -pub fn select(self: *TextArea) !void { +pub fn select(self: *TextArea, page: *Page) !void { const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0; - try self.setSelectionRange(0, len, null); + try self.setSelectionRange(0, len, null, page); } const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; @@ -141,6 +162,7 @@ pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void { self._selection_start = @intCast(new_value.len); self._selection_end = @intCast(new_value.len); self._selection_direction = .none; + try self.dispatchSelectionChangeEvent(page); }, .partial => |range| { // if the text area is partially selected, replace the selected content. @@ -159,6 +181,7 @@ pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void { self._selection_start = @intCast(new_pos); self._selection_end = @intCast(new_pos); self._selection_direction = .none; + try self.dispatchSelectionChangeEvent(page); }, .none => { // if the text area is not selected, just insert at cursor. @@ -177,19 +200,27 @@ pub fn getSelectionStart(self: *const TextArea) u32 { return self._selection_start; } -pub fn setSelectionStart(self: *TextArea, value: u32) void { +pub fn setSelectionStart(self: *TextArea, value: u32, page: *Page) !void { self._selection_start = value; + try self.dispatchSelectionChangeEvent(page); } pub fn getSelectionEnd(self: *const TextArea) u32 { return self._selection_end; } -pub fn setSelectionEnd(self: *TextArea, value: u32) void { +pub fn setSelectionEnd(self: *TextArea, value: u32, page: *Page) !void { self._selection_end = value; + try self.dispatchSelectionChangeEvent(page); } -pub fn setSelectionRange(self: *TextArea, selection_start: u32, selection_end: u32, selection_dir: ?[]const u8) !void { +pub fn setSelectionRange( + self: *TextArea, + selection_start: u32, + selection_end: u32, + selection_dir: ?[]const u8, + page: *Page, +) !void { const direction = blk: { if (selection_dir) |sd| { break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none; @@ -215,6 +246,8 @@ pub fn setSelectionRange(self: *TextArea, selection_start: u32, selection_end: u self._selection_direction = direction; self._selection_start = start; self._selection_end = end; + + try self.dispatchSelectionChangeEvent(page); } pub fn getForm(self: *TextArea, page: *Page) ?*Form { @@ -250,6 +283,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const onselectionchange = bridge.accessor(TextArea.getOnSelectionChange, TextArea.setOnSelectionChange, .{}); pub const value = bridge.accessor(TextArea.getValue, TextArea.setValue, .{}); pub const defaultValue = bridge.accessor(TextArea.getDefaultValue, TextArea.setDefaultValue, .{}); pub const disabled = bridge.accessor(TextArea.getDisabled, TextArea.setDisabled, .{}); From 8f15ded65098c91eb511cbae47debd70f02230b3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:44:32 -0800 Subject: [PATCH 06/11] use anyerror for dispatchSelectionChangeEvent --- src/browser/webapi/element/html/Input.zig | 2 +- src/browser/webapi/element/html/TextArea.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index eafd44d9..8512d53c 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -98,7 +98,7 @@ pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void { } } -fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void { +fn dispatchSelectionChangeEvent(self: *Input, page: *Page) anyerror!void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index d2c4d4fe..9a4ae360 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -50,7 +50,7 @@ pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void { } } -fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void { +fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) anyerror!void { const event = try Event.init("selectionchange", .{ .bubbles = true }, page); defer if (!event._v8_handoff) event.deinit(false); try page._event_manager.dispatch(self.asElement().asEventTarget(), event); From fcea42e91e3b0f6a902ce83cd2a21357afe48cf3 Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:51:17 -0800 Subject: [PATCH 07/11] properly expose selection api in HTMLTextAreaElement --- src/browser/webapi/element/html/TextArea.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index 9a4ae360..230114ee 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -290,6 +290,12 @@ pub const JsApi = struct { pub const name = bridge.accessor(TextArea.getName, TextArea.setName, .{}); pub const required = bridge.accessor(TextArea.getRequired, TextArea.setRequired, .{}); pub const form = bridge.accessor(TextArea.getForm, null, .{}); + pub const select = bridge.function(TextArea.select, .{}); + + pub const selectionStart = bridge.accessor(TextArea.getSelectionStart, TextArea.setSelectionStart, .{}); + pub const selectionEnd = bridge.accessor(TextArea.getSelectionEnd, TextArea.setSelectionEnd, .{}); + pub const selectionDirection = bridge.accessor(TextArea.getSelectionDirection, null, .{}); + pub const setSelectionRange = bridge.function(TextArea.setSelectionRange, .{ .dom_exception = true }); }; pub const Build = struct { From 73abf7d20e6e78944bd1a61f8488ad6f978cdbbb Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Mon, 9 Feb 2026 06:51:26 -0800 Subject: [PATCH 08/11] add tests for selectionchange in HTML Elements --- src/browser/tests/element/html/input.html | 43 ++++++++++++++++++++ src/browser/tests/element/html/textarea.html | 43 ++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 2cc51a9f..cd3129bc 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -183,6 +183,49 @@ } + +