diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig
index 6a0de8037..eb0613b16 100644
--- a/src/browser/Factory.zig
+++ b/src/browser/Factory.zig
@@ -30,6 +30,7 @@ const SlabAllocator = @import("../slab.zig").SlabAllocator;
const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
+const UIEvent = @import("webapi/event/UIEvent.zig");
const Element = @import("webapi/Element.zig");
const Document = @import("webapi/Document.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -170,11 +171,11 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
- // Special case: Event has a _type_string field, so we need manual setup
const chain = try PrototypeChain(
&.{ Event, @TypeOf(child) },
).allocate(allocator);
+ // Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(Event.Type, chain.get(1)),
@@ -185,6 +186,25 @@ pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
+pub fn uiEvent(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+
+ const chain = try PrototypeChain(
+ &.{ Event, UIEvent, @TypeOf(child) },
+ ).allocate(allocator);
+
+ // Special case: Event has a _type_string field, so we need manual setup
+ const event_ptr = chain.get(0);
+ event_ptr.* = .{
+ ._type = unionInit(Event.Type, chain.get(1)),
+ ._type_string = try String.init(self._page.arena, typ, .{}),
+ };
+ chain.setMiddle(1, UIEvent.Type);
+ chain.setLeaf(2, child);
+
+ return chain.get(2);
+}
+
pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index a37097e17..e3c7813f1 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -576,6 +576,9 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/NavigationCurrentEntryChangeEvent.zig"),
@import("../webapi/event/PageTransitionEvent.zig"),
@import("../webapi/event/PopStateEvent.zig"),
+ @import("../webapi/event/UIEvent.zig"),
+ @import("../webapi/event/MouseEvent.zig"),
+ @import("../webapi/event/KeyboardEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/media/MediaError.zig"),
diff --git a/src/browser/tests/event/keyboard.html b/src/browser/tests/event/keyboard.html
new file mode 100644
index 000000000..3d14cfca5
--- /dev/null
+++ b/src/browser/tests/event/keyboard.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/event/mouse.html b/src/browser/tests/event/mouse.html
new file mode 100644
index 000000000..4f8dd17fa
--- /dev/null
+++ b/src/browser/tests/event/mouse.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
diff --git a/src/browser/tests/event/ui.html b/src/browser/tests/event/ui.html
new file mode 100644
index 000000000..50d880ec3
--- /dev/null
+++ b/src/browser/tests/event/ui.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
index e99d71ece..23a0fa0e5 100644
--- a/src/browser/webapi/Event.zig
+++ b/src/browser/webapi/Event.zig
@@ -18,7 +18,6 @@
const std = @import("std");
const js = @import("../js/js.zig");
-const reflect = @import("../reflect.zig");
const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig");
@@ -60,9 +59,10 @@ pub const Type = union(enum) {
navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"),
page_transition_event: *@import("event/PageTransitionEvent.zig"),
pop_state_event: *@import("event/PopStateEvent.zig"),
+ ui_event: *@import("event/UIEvent.zig"),
};
-const Options = struct {
+pub const Options = struct {
bubbles: bool = false,
cancelable: bool = false,
composed: bool = false,
@@ -210,7 +210,7 @@ pub fn inheritOptions(comptime T: type, comptime additions: anytype) type {
inline for (t_fields) |field| {
if (std.mem.eql(u8, field.name, "_proto")) {
- const ProtoType = reflect.Struct(field.type);
+ const ProtoType = @typeInfo(field.type).pointer.child;
if (@hasDecl(ProtoType, "Options")) {
const parent_options = @typeInfo(ProtoType.Options);
all_fields = all_fields ++ parent_options.@"struct".fields;
diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig
new file mode 100644
index 000000000..288a5ccbb
--- /dev/null
+++ b/src/browser/webapi/event/KeyboardEvent.zig
@@ -0,0 +1,196 @@
+// Copyright (C) 2023-2025 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 Event = @import("../Event.zig");
+const UIEvent = @import("UIEvent.zig");
+const EventTarget = @import("../EventTarget.zig");
+const Window = @import("../Window.zig");
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const KeyboardEvent = @This();
+
+_proto: *UIEvent,
+_key: Key,
+_ctrl_key: bool,
+_shift_key: bool,
+_alt_key: bool,
+_meta_key: bool,
+_location: Location,
+_repeat: bool,
+_is_composing: bool,
+
+// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
+pub const Key = union(enum) {
+ // Special Key Values
+ Dead,
+ Undefined,
+ Alt,
+ AltGraph,
+ CapsLock,
+ Control,
+ Fn,
+ FnLock,
+ Hyper,
+ Meta,
+ NumLock,
+ ScrollLock,
+ Shift,
+ Super,
+ Symbol,
+ SymbolLock,
+ standard: []const u8,
+
+ pub fn fromString(allocator: std.mem.Allocator, str: []const u8) !Key {
+ const key_type_info = @typeInfo(Key);
+ inline for (key_type_info.@"union".fields) |field| {
+ if (comptime std.mem.eql(u8, field.name, "standard")) continue;
+
+ if (std.mem.eql(u8, field.name, str)) {
+ return @unionInit(Key, field.name, {});
+ }
+ }
+
+ const duped = try allocator.dupe(u8, str);
+ return .{ .standard = duped };
+ }
+};
+
+pub const Location = enum(i32) {
+ DOM_KEY_LOCATION_STANDARD = 0,
+ DOM_KEY_LOCATION_LEFT = 1,
+ DOM_KEY_LOCATION_RIGHT = 2,
+ DOM_KEY_LOCATION_NUMPAD = 3,
+};
+
+pub const KeyboardEventOptions = struct {
+ key: []const u8 = "",
+ // TODO: code but it is not baseline.
+ location: i32 = 0,
+ repeat: bool = false,
+ isComposing: bool = false,
+ ctrlKey: bool = false,
+ shiftKey: bool = false,
+ altKey: bool = false,
+ metaKey: bool = false,
+};
+
+pub const Options = Event.inheritOptions(
+ KeyboardEvent,
+ KeyboardEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*KeyboardEvent {
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.uiEvent(
+ typ,
+ KeyboardEvent{
+ ._proto = undefined,
+ ._key = try Key.fromString(page.arena, opts.key),
+ ._location = std.meta.intToEnum(Location, opts.location) catch return error.TypeError,
+ ._repeat = opts.repeat,
+ ._is_composing = opts.isComposing,
+ ._ctrl_key = opts.ctrlKey,
+ ._shift_key = opts.shiftKey,
+ ._alt_key = opts.altKey,
+ ._meta_key = opts.metaKey,
+ },
+ );
+
+ Event.populatePrototypes(event, opts);
+ return event;
+}
+
+pub fn asEvent(self: *KeyboardEvent) *Event {
+ return self._proto.asEvent();
+}
+
+pub fn getAltKey(self: *const KeyboardEvent) bool {
+ return self._alt_key;
+}
+
+pub fn getCtrlKey(self: *const KeyboardEvent) bool {
+ return self._ctrl_key;
+}
+
+pub fn getIsComposing(self: *const KeyboardEvent) bool {
+ return self._is_composing;
+}
+
+pub fn getKey(self: *const KeyboardEvent) []const u8 {
+ return switch (self._key) {
+ .standard => |key| key,
+ else => |x| @tagName(x),
+ };
+}
+
+pub fn getLocation(self: *const KeyboardEvent) i32 {
+ return @intFromEnum(self._location);
+}
+
+pub fn getMetaKey(self: *const KeyboardEvent) bool {
+ return self._meta_key;
+}
+
+pub fn getRepeat(self: *const KeyboardEvent) bool {
+ return self._repeat;
+}
+
+pub fn getShiftKey(self: *const KeyboardEvent) bool {
+ return self._shift_key;
+}
+
+pub fn getModifierState(self: *const KeyboardEvent, str: []const u8, page: *Page) !bool {
+ const key = try Key.fromString(page.arena, str);
+
+ switch (key) {
+ .Alt, .AltGraph => return self._alt_key,
+ .Shift => return self._shift_key,
+ .Control => return self._ctrl_key,
+ .Meta => return self._meta_key,
+ else => return false,
+ }
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(KeyboardEvent);
+
+ pub const Meta = struct {
+ pub const name = "KeyboardEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(KeyboardEvent.init, .{});
+ pub const altKey = bridge.accessor(KeyboardEvent.getAltKey, null, .{});
+ pub const ctrlKey = bridge.accessor(KeyboardEvent.getCtrlKey, null, .{});
+ pub const isComposing = bridge.accessor(KeyboardEvent.getIsComposing, null, .{});
+ pub const key = bridge.accessor(KeyboardEvent.getKey, null, .{});
+ pub const location = bridge.accessor(KeyboardEvent.getLocation, null, .{});
+ pub const metaKey = bridge.accessor(KeyboardEvent.getMetaKey, null, .{});
+ pub const repeat = bridge.accessor(KeyboardEvent.getRepeat, null, .{});
+ pub const shiftKey = bridge.accessor(KeyboardEvent.getShiftKey, null, .{});
+ pub const getModifierState = bridge.function(KeyboardEvent.getModifierState, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: KeyboardEvent" {
+ try testing.htmlRunner("event/keyboard.html", .{});
+}
diff --git a/src/browser/webapi/event/MouseEvent.zig b/src/browser/webapi/event/MouseEvent.zig
new file mode 100644
index 000000000..305d4c86d
--- /dev/null
+++ b/src/browser/webapi/event/MouseEvent.zig
@@ -0,0 +1,188 @@
+// Copyright (C) 2023-2025 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 Event = @import("../Event.zig");
+const UIEvent = @import("UIEvent.zig");
+const EventTarget = @import("../EventTarget.zig");
+const Window = @import("../Window.zig");
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const MouseEvent = @This();
+
+const MouseButton = enum(u8) {
+ main = 0,
+ auxillary = 1,
+ secondary = 2,
+ fourth = 3,
+ fifth = 4,
+};
+
+_proto: *UIEvent,
+
+_alt_key: bool,
+_button: MouseButton,
+// TODO: _buttons
+_client_x: f64,
+_client_y: f64,
+_ctrl_key: bool,
+_meta_key: bool,
+// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget
+_related_target: ?*EventTarget = null,
+_screen_x: f64,
+_screen_y: f64,
+_shift_key: bool,
+
+pub const MouseEventOptions = struct {
+ screenX: f64 = 0.0,
+ screenY: f64 = 0.0,
+ clientX: f64 = 0.0,
+ clientY: f64 = 0.0,
+ ctrlKey: bool = false,
+ shiftKey: bool = false,
+ altKey: bool = false,
+ metaKey: bool = false,
+ button: i32 = 0,
+ // TODO: buttons
+ relatedTarget: ?*EventTarget = null,
+};
+
+pub const Options = Event.inheritOptions(
+ MouseEvent,
+ MouseEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.uiEvent(
+ typ,
+ MouseEvent{
+ ._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(MouseButton, opts.button) catch return error.TypeError,
+ ._related_target = opts.relatedTarget,
+ },
+ );
+
+ Event.populatePrototypes(event, opts);
+ return event;
+}
+
+pub fn asEvent(self: *MouseEvent) *Event {
+ return self._proto.asEvent();
+}
+
+pub fn getAltKey(self: *const MouseEvent) bool {
+ return self._alt_key;
+}
+
+pub fn getButton(self: *const MouseEvent) u8 {
+ return @intFromEnum(self._button);
+}
+
+pub fn getClientX(self: *const MouseEvent) f64 {
+ return self._client_x;
+}
+
+pub fn getClientY(self: *const MouseEvent) f64 {
+ return self._client_y;
+}
+
+pub fn getCtrlKey(self: *const MouseEvent) bool {
+ return self._ctrl_key;
+}
+
+pub fn getMetaKey(self: *const MouseEvent) bool {
+ return self._meta_key;
+}
+
+pub fn getOffsetX(_: *const MouseEvent) f64 {
+ return 0.0;
+}
+
+pub fn getOffsetY(_: *const MouseEvent) f64 {
+ return 0.0;
+}
+
+pub fn getPageX(self: *const MouseEvent) f64 {
+ // this should be clientX + window.scrollX
+ return self._client_x;
+}
+
+pub fn getPageY(self: *const MouseEvent) f64 {
+ // this should be clientY + window.scrollY
+ return self._client_y;
+}
+
+pub fn getRelatedTarget(self: *const MouseEvent) ?*EventTarget {
+ return self._related_target;
+}
+
+pub fn getScreenX(self: *const MouseEvent) f64 {
+ return self._screen_x;
+}
+
+pub fn getScreenY(self: *const MouseEvent) f64 {
+ return self._screen_y;
+}
+
+pub fn getShiftKey(self: *const MouseEvent) bool {
+ return self._shift_key;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MouseEvent);
+
+ pub const Meta = struct {
+ pub const name = "MouseEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(MouseEvent.init, .{});
+ pub const altKey = bridge.accessor(getAltKey, null, .{});
+ pub const button = bridge.accessor(getButton, null, .{});
+ pub const clientX = bridge.accessor(getClientX, null, .{});
+ pub const clientY = bridge.accessor(getClientY, null, .{});
+ pub const ctrlKey = bridge.accessor(getCtrlKey, null, .{});
+ pub const metaKey = bridge.accessor(getMetaKey, null, .{});
+ pub const offsetX = bridge.accessor(getOffsetX, null, .{});
+ pub const offsetY = bridge.accessor(getOffsetY, null, .{});
+ pub const pageX = bridge.accessor(getPageX, null, .{});
+ pub const pageY = bridge.accessor(getPageY, null, .{});
+ pub const relatedTarget = bridge.accessor(getRelatedTarget, null, .{});
+ pub const screenX = bridge.accessor(getScreenX, null, .{});
+ pub const screenY = bridge.accessor(getScreenY, null, .{});
+ pub const shiftKey = bridge.accessor(getShiftKey, null, .{});
+ pub const x = bridge.accessor(getClientX, null, .{});
+ pub const y = bridge.accessor(getClientY, null, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: MouseEvent" {
+ try testing.htmlRunner("event/mouse.html", .{});
+}
diff --git a/src/browser/webapi/event/UIEvent.zig b/src/browser/webapi/event/UIEvent.zig
new file mode 100644
index 000000000..75a58cd61
--- /dev/null
+++ b/src/browser/webapi/event/UIEvent.zig
@@ -0,0 +1,102 @@
+// Copyright (C) 2023-2025 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 Event = @import("../Event.zig");
+const Window = @import("../Window.zig");
+const Page = @import("../../Page.zig");
+const js = @import("../../js/js.zig");
+
+const UIEvent = @This();
+
+_type: Type,
+_proto: *Event,
+_detail: u32 = 0,
+_view: ?*Window = null,
+
+pub const Type = union(enum) {
+ generic,
+ mouse_event: *@import("MouseEvent.zig"),
+ keyboard_event: *@import("KeyboardEvent.zig"),
+};
+
+pub const UIEventOptions = struct {
+ detail: u32 = 0,
+ view: ?*Window = null,
+};
+
+pub const Options = Event.inheritOptions(
+ UIEvent,
+ UIEventOptions,
+);
+
+pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*UIEvent {
+ const opts = _opts orelse Options{};
+
+ const event = try page._factory.event(
+ typ,
+ UIEvent{
+ ._type = .generic,
+ ._proto = undefined,
+ ._detail = opts.detail,
+ ._view = opts.view orelse page.window,
+ },
+ );
+
+ Event.populatePrototypes(event, opts);
+ return event;
+}
+
+pub fn populateFromOptions(self: *UIEvent, opts: anytype) void {
+ self._detail = opts.detail;
+ self._view = opts.view;
+}
+
+pub fn asEvent(self: *UIEvent) *Event {
+ return self._proto;
+}
+
+pub fn getDetail(self: *UIEvent) u32 {
+ return self._detail;
+}
+
+// sourceCapabilities not implemented
+
+pub fn getView(self: *UIEvent, page: *Page) *Window {
+ return self._view orelse page.window;
+}
+
+// deprecated `initUIEvent()` not implemented
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(UIEvent);
+
+ pub const Meta = struct {
+ pub const name = "UIEvent";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(UIEvent.init, .{});
+ pub const detail = bridge.accessor(UIEvent.getDetail, null, .{});
+ pub const view = bridge.accessor(UIEvent.getView, null, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: UIEvent" {
+ try testing.htmlRunner("event/ui.html", .{});
+}