diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index 2d866733..2600e288 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -184,6 +184,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void
.navigation,
.screen,
.screen_orientation,
+ .visual_viewport,
.generic,
=> {
const list = self.lookup.get(.{
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index f66394de..f3a5278f 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -51,6 +51,7 @@ const Document = @import("webapi/Document.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
+const VisualViewport = @import("webapi/VisualViewport.zig");
const PerformanceObserver = @import("webapi/PerformanceObserver.zig");
const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
@@ -306,6 +307,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
const storage_bucket = try self._factory.create(storage.Bucket{});
const screen = try Screen.init(self);
+ const visual_viewport = try VisualViewport.init(self);
self.window = try self._factory.eventTarget(Window{
._document = self.document,
._storage_bucket = storage_bucket,
@@ -313,6 +315,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
._proto = undefined,
._location = &default_location,
._screen = screen,
+ ._visual_viewport = visual_viewport,
});
self.window._document = self.document;
self.window._location = &default_location;
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index aa477d3e..1334172b 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -914,6 +914,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/Blob.zig"),
@import("../webapi/File.zig"),
@import("../webapi/Screen.zig"),
+ @import("../webapi/VisualViewport.zig"),
@import("../webapi/PerformanceObserver.zig"),
@import("../webapi/navigation/Navigation.zig"),
@import("../webapi/navigation/NavigationEventTarget.zig"),
diff --git a/src/browser/tests/window/visual_viewport.html b/src/browser/tests/window/visual_viewport.html
new file mode 100644
index 00000000..bcde9ace
--- /dev/null
+++ b/src/browser/tests/window/visual_viewport.html
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig
index e15dc243..39613f67 100644
--- a/src/browser/webapi/EventTarget.zig
+++ b/src/browser/webapi/EventTarget.zig
@@ -42,6 +42,7 @@ pub const Type = union(enum) {
navigation: *@import("navigation/NavigationEventTarget.zig"),
screen: *@import("Screen.zig"),
screen_orientation: *@import("Screen.zig").Orientation,
+ visual_viewport: *@import("VisualViewport.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -132,6 +133,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.navigation => writer.writeAll(""),
.screen => writer.writeAll(""),
.screen_orientation => writer.writeAll(""),
+ .visual_viewport => writer.writeAll(""),
};
}
@@ -148,6 +150,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.navigation => return "[object Navigation]",
.screen => return "[object Screen]",
.screen_orientation => return "[object ScreenOrientation]",
+ .visual_viewport => return "[object VisualViewport]",
};
}
diff --git a/src/browser/webapi/VisualViewport.zig b/src/browser/webapi/VisualViewport.zig
new file mode 100644
index 00000000..afbab13e
--- /dev/null
+++ b/src/browser/webapi/VisualViewport.zig
@@ -0,0 +1,64 @@
+// 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 js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const EventTarget = @import("EventTarget.zig");
+const Window = @import("Window.zig");
+
+const VisualViewport = @This();
+
+_proto: *EventTarget,
+
+pub fn init(page: *Page) !*VisualViewport {
+ return page._factory.eventTarget(VisualViewport{
+ ._proto = undefined,
+ });
+}
+
+pub fn asEventTarget(self: *VisualViewport) *EventTarget {
+ return self._proto;
+}
+
+pub fn getPageLeft(_: *const VisualViewport, page: *Page) u32 {
+ return page.window.getScrollX();
+}
+
+pub fn getPageTop(_: *const VisualViewport, page: *Page) u32 {
+ return page.window.getScrollY();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(VisualViewport);
+
+ pub const Meta = struct {
+ pub const name = "VisualViewport";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ // Static viewport properties for headless browser
+ // No pinch-zoom or mobile viewport, so values are straightforward
+ pub const offsetLeft = bridge.property(0, .{ .template = false });
+ pub const offsetTop = bridge.property(0, .{ .template = false });
+ pub const pageLeft = bridge.accessor(VisualViewport.getPageLeft, null, .{});
+ pub const pageTop = bridge.accessor(VisualViewport.getPageTop, null, .{});
+ pub const width = bridge.property(1920, .{ .template = false });
+ pub const height = bridge.property(1080, .{ .template = false });
+ pub const scale = bridge.property(1.0, .{ .template = false });
+};
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
index e0de7693..b7df959b 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -29,6 +29,7 @@ const Crypto = @import("Crypto.zig");
const CSS = @import("CSS.zig");
const Navigator = @import("Navigator.zig");
const Screen = @import("Screen.zig");
+const VisualViewport = @import("VisualViewport.zig");
const Performance = @import("Performance.zig");
const Document = @import("Document.zig");
const Location = @import("Location.zig");
@@ -57,6 +58,7 @@ _crypto: Crypto = .init,
_console: Console = .init,
_navigator: Navigator = .init,
_screen: *Screen,
+_visual_viewport: *VisualViewport,
_performance: Performance,
_storage_bucket: *storage.Bucket,
_on_load: ?js.Function.Global = null,
@@ -110,6 +112,10 @@ pub fn getScreen(self: *Window) *Screen {
return self._screen;
}
+pub fn getVisualViewport(self: *const Window) *VisualViewport {
+ return self._visual_viewport;
+}
+
pub fn getCrypto(self: *Window) *Crypto {
return &self._crypto;
}
@@ -714,6 +720,7 @@ pub const JsApi = struct {
pub const console = bridge.accessor(Window.getConsole, null, .{});
pub const navigator = bridge.accessor(Window.getNavigator, null, .{});
pub const screen = bridge.accessor(Window.getScreen, null, .{});
+ pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{});
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});