diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index c6f677a0..623d898e 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -817,6 +817,9 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/MouseEvent.zig"),
@import("../webapi/event/PointerEvent.zig"),
@import("../webapi/event/KeyboardEvent.zig"),
+ @import("../webapi/event/FocusEvent.zig"),
+ @import("../webapi/event/WheelEvent.zig"),
+ @import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
diff --git a/src/browser/tests/event/focus.html b/src/browser/tests/event/focus.html
new file mode 100644
index 00000000..ac5b2a51
--- /dev/null
+++ b/src/browser/tests/event/focus.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/src/browser/tests/event/text.html b/src/browser/tests/event/text.html
new file mode 100644
index 00000000..f885c293
--- /dev/null
+++ b/src/browser/tests/event/text.html
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/src/browser/tests/event/wheel.html b/src/browser/tests/event/wheel.html
new file mode 100644
index 00000000..55a5c925
--- /dev/null
+++ b/src/browser/tests/event/wheel.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 8a3f695b..eb8f0c41 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -358,6 +358,16 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
return (try UIEvent.init("", null, page)).asEvent();
}
+ if (std.mem.eql(u8, normalized, "focusevent") or std.mem.eql(u8, normalized, "focusevents")) {
+ const FocusEvent = @import("event/FocusEvent.zig");
+ return (try FocusEvent.init("", null, page)).asEvent();
+ }
+
+ if (std.mem.eql(u8, normalized, "textevent") or std.mem.eql(u8, normalized, "textevents")) {
+ const TextEvent = @import("event/TextEvent.zig");
+ return (try TextEvent.init("", null, page)).asEvent();
+ }
+
return error.NotSupported;
}
diff --git a/src/browser/webapi/event/FocusEvent.zig b/src/browser/webapi/event/FocusEvent.zig
new file mode 100644
index 00000000..46032bee
--- /dev/null
+++ b/src/browser/webapi/event/FocusEvent.zig
@@ -0,0 +1,91 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const String = @import("../../../string.zig").String;
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const Event = @import("../Event.zig");
+const EventTarget = @import("../EventTarget.zig");
+const UIEvent = @import("UIEvent.zig");
+
+const FocusEvent = @This();
+
+_proto: *UIEvent,
+_related_target: ?*EventTarget = null,
+
+pub const FocusEventOptions = struct {
+ relatedTarget: ?*EventTarget = null,
+};
+
+pub const Options = Event.inheritOptions(
+ FocusEvent,
+ FocusEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*FocusEvent {
+ const arena = try page.getArena(.{ .debug = "FocusEvent" });
+ errdefer page.releaseArena(arena);
+ const type_string = try String.init(arena, typ, .{});
+
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.uiEvent(
+ arena,
+ type_string,
+ FocusEvent{
+ ._proto = undefined,
+ ._related_target = opts.relatedTarget,
+ },
+ );
+
+ Event.populatePrototypes(event, opts, false);
+ return event;
+}
+
+pub fn deinit(self: *FocusEvent, shutdown: bool) void {
+ self._proto.deinit(shutdown);
+}
+
+pub fn asEvent(self: *FocusEvent) *Event {
+ return self._proto.asEvent();
+}
+
+pub fn getRelatedTarget(self: *const FocusEvent) ?*EventTarget {
+ return self._related_target;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(FocusEvent);
+
+ pub const Meta = struct {
+ pub const name = "FocusEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const weak = true;
+ pub const finalizer = bridge.finalizer(FocusEvent.deinit);
+ };
+
+ pub const constructor = bridge.constructor(FocusEvent.init, .{});
+ pub const relatedTarget = bridge.accessor(FocusEvent.getRelatedTarget, null, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: FocusEvent" {
+ try testing.htmlRunner("event/focus.html", .{});
+}
diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig
index f2eac422..a687aeaf 100644
--- a/src/browser/webapi/event/MouseEvent.zig
+++ b/src/browser/webapi/event/MouseEvent.zig
@@ -40,6 +40,7 @@ pub const MouseButton = enum(u8) {
pub const Type = union(enum) {
generic,
pointer_event: *PointerEvent,
+ wheel_event: *@import("WheelEvent.zig"),
};
_type: Type,
@@ -123,6 +124,7 @@ pub fn is(self: *MouseEvent, comptime T: type) ?*T {
switch (self._type) {
.generic => return if (T == MouseEvent) self else null,
.pointer_event => |e| return if (T == PointerEvent) e else null,
+ .wheel_event => |e| return if (T == @import("WheelEvent.zig")) e else null,
}
return null;
}
diff --git a/src/browser/webapi/event/TextEvent.zig b/src/browser/webapi/event/TextEvent.zig
new file mode 100644
index 00000000..65b16db8
--- /dev/null
+++ b/src/browser/webapi/event/TextEvent.zig
@@ -0,0 +1,115 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const String = @import("../../../string.zig").String;
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const Event = @import("../Event.zig");
+const UIEvent = @import("UIEvent.zig");
+
+const TextEvent = @This();
+
+_proto: *UIEvent,
+_data: []const u8 = "",
+
+pub const TextEventOptions = struct {
+ data: ?[]const u8 = null,
+};
+
+pub const Options = Event.inheritOptions(
+ TextEvent,
+ TextEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*TextEvent {
+ const arena = try page.getArena(.{ .debug = "TextEvent" });
+ errdefer page.releaseArena(arena);
+ const type_string = try String.init(arena, typ, .{});
+
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.uiEvent(
+ arena,
+ type_string,
+ TextEvent{
+ ._proto = undefined,
+ ._data = if (opts.data) |str| try arena.dupe(u8, str) else "",
+ },
+ );
+
+ Event.populatePrototypes(event, opts, false);
+ return event;
+}
+
+pub fn deinit(self: *TextEvent, shutdown: bool) void {
+ self._proto.deinit(shutdown);
+}
+
+pub fn asEvent(self: *TextEvent) *Event {
+ return self._proto.asEvent();
+}
+
+pub fn getData(self: *const TextEvent) []const u8 {
+ return self._data;
+}
+
+pub fn initTextEvent(
+ self: *TextEvent,
+ typ: []const u8,
+ bubbles: bool,
+ cancelable: bool,
+ view: ?*@import("../Window.zig"),
+ data: []const u8,
+) !void {
+ _ = view; // view parameter is ignored in modern implementations
+
+ const event = self._proto._proto;
+ if (event._event_phase != .none) {
+ // Only allow initialization if event hasn't been dispatched
+ return;
+ }
+
+ const arena = event._arena;
+ event._type_string = try String.init(arena, typ, .{});
+ event._bubbles = bubbles;
+ event._cancelable = cancelable;
+ self._data = try arena.dupe(u8, data);
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(TextEvent);
+
+ pub const Meta = struct {
+ pub const name = "TextEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const weak = true;
+ pub const finalizer = bridge.finalizer(TextEvent.deinit);
+ };
+
+ // No constructor - TextEvent is created via document.createEvent('TextEvent')
+ pub const data = bridge.accessor(TextEvent.getData, null, .{});
+ pub const initTextEvent = bridge.function(TextEvent.initTextEvent, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: TextEvent" {
+ try testing.htmlRunner("event/text.html", .{});
+}
diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig
index 6e50603e..b708dbd9 100644
--- a/src/browser/webapi/event/UIEvent.zig
+++ b/src/browser/webapi/event/UIEvent.zig
@@ -34,6 +34,8 @@ pub const Type = union(enum) {
generic,
mouse_event: *@import("MouseEvent.zig"),
keyboard_event: *@import("KeyboardEvent.zig"),
+ focus_event: *@import("FocusEvent.zig"),
+ text_event: *@import("TextEvent.zig"),
};
pub const UIEventOptions = struct {
@@ -83,6 +85,8 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T {
return e.is(T);
},
.keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null,
+ .focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null,
+ .text_event => |e| return if (T == @import("TextEvent.zig")) e else null,
}
return null;
}
diff --git a/src/browser/webapi/event/WheelEvent.zig b/src/browser/webapi/event/WheelEvent.zig
new file mode 100644
index 00000000..665e1d49
--- /dev/null
+++ b/src/browser/webapi/event/WheelEvent.zig
@@ -0,0 +1,136 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const String = @import("../../../string.zig").String;
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const Event = @import("../Event.zig");
+const MouseEvent = @import("MouseEvent.zig");
+
+const WheelEvent = @This();
+
+_proto: *MouseEvent,
+_delta_x: f64,
+_delta_y: f64,
+_delta_z: f64,
+_delta_mode: u32,
+
+pub const DOM_DELTA_PIXEL: u32 = 0x00;
+pub const DOM_DELTA_LINE: u32 = 0x01;
+pub const DOM_DELTA_PAGE: u32 = 0x02;
+
+pub const WheelEventOptions = struct {
+ deltaX: f64 = 0.0,
+ deltaY: f64 = 0.0,
+ deltaZ: f64 = 0.0,
+ deltaMode: u32 = 0,
+};
+
+pub const Options = Event.inheritOptions(
+ WheelEvent,
+ WheelEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*WheelEvent {
+ const arena = try page.getArena(.{ .debug = "WheelEvent" });
+ errdefer page.releaseArena(arena);
+ const type_string = try String.init(arena, typ, .{});
+
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.mouseEvent(
+ arena,
+ type_string,
+ MouseEvent{
+ ._type = .{ .wheel_event = undefined },
+ ._proto = undefined,
+ ._screen_x = opts.screenX,
+ ._screen_y = opts.screenY,
+ ._client_x = opts.clientX,
+ ._client_y = opts.clientY,
+ ._ctrl_key = opts.ctrlKey,
+ ._shift_key = opts.shiftKey,
+ ._alt_key = opts.altKey,
+ ._meta_key = opts.metaKey,
+ ._button = std.meta.intToEnum(MouseEvent.MouseButton, opts.button) catch return error.TypeError,
+ ._related_target = opts.relatedTarget,
+ },
+ WheelEvent{
+ ._proto = undefined,
+ ._delta_x = opts.deltaX,
+ ._delta_y = opts.deltaY,
+ ._delta_z = opts.deltaZ,
+ ._delta_mode = opts.deltaMode,
+ },
+ );
+
+ Event.populatePrototypes(event, opts, false);
+ return event;
+}
+
+pub fn deinit(self: *WheelEvent, shutdown: bool) void {
+ self._proto.deinit(shutdown);
+}
+
+pub fn asEvent(self: *WheelEvent) *Event {
+ return self._proto.asEvent();
+}
+
+pub fn getDeltaX(self: *const WheelEvent) f64 {
+ return self._delta_x;
+}
+
+pub fn getDeltaY(self: *const WheelEvent) f64 {
+ return self._delta_y;
+}
+
+pub fn getDeltaZ(self: *const WheelEvent) f64 {
+ return self._delta_z;
+}
+
+pub fn getDeltaMode(self: *const WheelEvent) u32 {
+ return self._delta_mode;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(WheelEvent);
+
+ pub const Meta = struct {
+ pub const name = "WheelEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const weak = true;
+ pub const finalizer = bridge.finalizer(WheelEvent.deinit);
+ };
+
+ pub const constructor = bridge.constructor(WheelEvent.init, .{});
+ pub const deltaX = bridge.accessor(WheelEvent.getDeltaX, null, .{});
+ pub const deltaY = bridge.accessor(WheelEvent.getDeltaY, null, .{});
+ pub const deltaZ = bridge.accessor(WheelEvent.getDeltaZ, null, .{});
+ pub const deltaMode = bridge.accessor(WheelEvent.getDeltaMode, null, .{});
+ pub const DOM_DELTA_PIXEL = bridge.property(WheelEvent.DOM_DELTA_PIXEL, .{ .template = true });
+ pub const DOM_DELTA_LINE = bridge.property(WheelEvent.DOM_DELTA_LINE, .{ .template = true });
+ pub const DOM_DELTA_PAGE = bridge.property(WheelEvent.DOM_DELTA_PAGE, .{ .template = true });
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: WheelEvent" {
+ try testing.htmlRunner("event/wheel.html", .{});
+}