Files
browser/src/browser/webapi/Element.zig
T
Adrià Arrufat 51b9ee8125 Extend the document to nested declared heights
A spacer inside an auto-height wrapper, as virtualized lists build,
counted as 5px, so the viewport clamp stopped short of it.
2026-09-28 09:40:32 +02:00

2851 lines
102 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const dump = @import("../dump.zig");
const Frame = @import("../Frame.zig");
const Factory = @import("../Factory.zig");
const text_measure = @import("../text_measure.zig");
const CSS = @import("CSS.zig");
const Node = @import("Node.zig");
const TreeWalker = @import("TreeWalker.zig");
const ShadowRoot = @import("ShadowRoot.zig");
const EventTarget = @import("EventTarget.zig");
const collections = @import("collections.zig");
const Selector = @import("selector/Selector.zig");
const Animation = @import("animation/Animation.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const slotting = @import("element/slotting.zig");
const DOMStringMap = @import("element/DOMStringMap.zig");
pub const DOMRect = @import("DOMRect.zig");
pub const Svg = @import("element/Svg.zig");
pub const Html = @import("element/Html.zig");
pub const Attribute = @import("element/Attribute.zig");
pub const Reflect = @import("element/reflection.zig").Reflect;
const log = lp.log;
const String = lp.String;
const Element = @This();
pub const Proto = Node;
pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap);
pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
pub const ComputedStyleLookup = std.AutoHashMapUnmanaged(ComputedStyleKey, *CSSStyleProperties);
pub const ComputedStyleKey = struct {
element: *Element,
pseudo: PseudoElement,
};
pub const PseudoElement = enum {
none,
before,
after,
other,
pub fn parse(pseudo: []const u8) PseudoElement {
if (pseudo.len == 0 or pseudo[0] != ':') {
return .none;
}
const name = if (std.mem.startsWith(u8, pseudo, "::")) pseudo[2..] else pseudo[1..];
if (std.ascii.eqlIgnoreCase(name, "before")) return .before;
if (std.ascii.eqlIgnoreCase(name, "after")) return .after;
return .other;
}
};
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const PartListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
pub const NamespaceUriLookup = std.AutoHashMapUnmanaged(*Element, []const u8);
pub const ScrollPosition = struct {
x: u32 = 0,
y: u32 = 0,
// Throttle state for the async scroll/scrollend dispatch (mirrors
// Window._scroll_pos.state).
state: enum { scroll, end, done } = .done,
};
pub const ScrollPositionLookup = std.AutoHashMapUnmanaged(*Element, ScrollPosition);
pub const Namespace = enum(u8) {
html,
svg,
mathml,
xml,
// We should keep the original value, but don't. If this becomes important
// consider storing it in a frame lookup, like `_element_class_lists`, rather
// that adding a slice directly here (directly in every element).
unknown,
null,
pub fn toUri(self: Namespace) ?[]const u8 {
return switch (self) {
.html => "http://www.w3.org/1999/xhtml",
.svg => "http://www.w3.org/2000/svg",
.mathml => "http://www.w3.org/1998/Math/MathML",
.xml => "http://www.w3.org/XML/1998/namespace",
.unknown => "http://lightpanda.io/unsupported/namespace",
.null => null,
};
}
pub fn parse(namespace_: ?[]const u8) Namespace {
const namespace = namespace_ orelse return .null;
if (namespace.len == 0) {
return .null;
}
if (namespace.len == "http://www.w3.org/1999/xhtml".len) {
// Common case, avoid the string comparison. Recklessly
@branchHint(.likely);
return .html;
}
if (std.mem.eql(u8, namespace, "http://www.w3.org/XML/1998/namespace")) {
return .xml;
}
if (std.mem.eql(u8, namespace, "http://www.w3.org/2000/svg")) {
return .svg;
}
if (std.mem.eql(u8, namespace, "http://www.w3.org/1998/Math/MathML")) {
return .mathml;
}
return .unknown;
}
};
pub const Flags = packed struct(u8) {
shadow_host: bool = false,
customized_builtin: bool = false,
// Prevents nested clicks (which have a specific spec-compliant behavior
// compared to other events). If this bit can be more useful for something
// else, a stack in EventManager (for click-specifically) is an alterantive
// approach
click_in_progress: bool = false,
// The element may have inline style: a materialized entry in the frame's
// _element_styles, or a `style` attribute not yet materialized. Set once,
// never cleared. A clear bit lets the faux layout skip both the map probe
// and the attribute scan for the many elements that have neither.
has_inline_style: bool = false,
_unused: u4 = 0,
};
_type: Type,
_namespace: Namespace = .html,
// Presence hints for the frame's element-keyed side tables: a set bit means
// "maybe in the map" (the map stays the authority), a clear bit skips the
// lookup. Turns the per-element map probe in tree walks into a bit test on
// memory the walk already touches. Fits in existing struct padding.
_flags: Flags = .{},
_attributes: Attribute.List = .{},
// In debug, set so that we can check that we have a proper contiguous block
// of memory for the entire chain (and thus, simple pointer arithmetics will
// work to resolve the proto).
_proto_canary: if (lp.IS_DEBUG) *Node else void = undefined,
pub const Type = enum(u8) {
html,
svg,
};
pub fn Subtype(comptime tag: Type) type {
return switch (tag) {
.html => Html,
.svg => Svg,
};
}
pub fn subtype(self: *const Element, comptime T: type) *T {
const offset = comptime Factory.chainOffsetOf(T, T) - Factory.chainOffsetOf(T, Element);
const sub: *T = @ptrFromInt(@intFromPtr(self) + offset);
if (comptime lp.IS_DEBUG) {
// This pointer dance only works because the factory allocates the chain
// in a contiguous block of memory. In debug, we assert this holds via
// the _proto_canary back pointer.
std.debug.assert(Factory.protoOf(sub) == self);
}
return sub;
}
pub fn is(self: *Element, comptime T: type) ?*T {
const type_name = @typeName(T);
switch (self._type) {
.html => {
const el = self.subtype(Html);
if (T == Html) {
return el;
}
if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.")) {
return el.is(T);
}
},
.svg => {
const svg = self.subtype(Svg);
if (T == Svg) {
return svg;
}
if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.svg.")) {
return svg.is(T);
}
},
}
return null;
}
pub fn as(self: *Element, comptime T: type) *T {
return self.is(T).?;
}
pub fn getDocument(self: *Element, frame: *const Frame) *Node.Document {
return self.asNode().getDocument(frame);
}
pub fn asNode(self: *Element) *Node {
return Factory.protoOf(self);
}
pub fn asEventTarget(self: *Element) *EventTarget {
return self.asNode().asEventTarget();
}
pub fn asConstNode(self: *const Element) *const Node {
return Factory.protoOf(@constCast(self));
}
/// TODO: localName and prefix comparison.
pub fn isEqualNode(self: *Element, other: *Element) bool {
const self_tag = self.getTagNameDump();
const other_tag = other.getTagNameDump();
// Compare namespaces and tags.
const dirty = self._namespace != other._namespace or !std.mem.eql(u8, self_tag, other_tag);
if (dirty) {
return false;
}
if (self._attributes.eql(&other._attributes) == false) {
return false;
}
return self.asNode().isEqualChildren(other.asNode());
}
pub fn getTagNameLower(self: *const Element) []const u8 {
switch (self._type) {
.html => {
const he = self.subtype(Html);
switch (he._type) {
.custom => {
@branchHint(.unlikely);
return he.subtype(Html.Custom)._tag_name.str();
},
else => return switch (he._type) {
.anchor => "a",
.area => "area",
.base => "base",
.body => "body",
.br => "br",
.button => "button",
.canvas => "canvas",
.custom => he.subtype(Html.Custom)._tag_name.str(),
.data => "data",
.datalist => "datalist",
.details => "details",
.dialog => "dialog",
.directory => "dir",
.div => "div",
.dl => "dl",
.embed => "embed",
.fieldset => "fieldset",
.font => "font",
.frameset => "frameset",
.form => "form",
.generic => he.subtype(Html.Generic)._tag_name.str(),
.heading => he.subtype(Html.Heading)._tag_name.str(),
.head => "head",
.html => "html",
.hr => "hr",
.iframe => "iframe",
.img => "img",
.input => "input",
.label => "label",
.legend => "legend",
.li => "li",
.link => "link",
.map => "map",
.marquee => "marquee",
.media => switch (he.subtype(Html.Media)._type) {
.audio => "audio",
.video => "video",
.generic => "media",
},
.meta => "meta",
.meter => "meter",
.mod => he.subtype(Html.Mod)._tag_name.str(),
.object => "object",
.ol => "ol",
.optgroup => "optgroup",
.option => "option",
.output => "output",
.p => "p",
.picture => "picture",
.param => "param",
.pre => "pre",
.progress => "progress",
.quote => he.subtype(Html.Quote)._tag_name.str(),
.script => "script",
.select => "select",
.slot => "slot",
.source => "source",
.span => "span",
.style => "style",
.table => "table",
.table_caption => "caption",
.table_cell => he.subtype(Html.TableCell)._tag_name.str(),
.table_col => he.subtype(Html.TableCol)._tag_name.str(),
.table_row => "tr",
.table_section => he.subtype(Html.TableSection)._tag_name.str(),
.template => "template",
.textarea => "textarea",
.time => "time",
.title => "title",
.track => "track",
.ul => "ul",
.unknown => he.subtype(Html.Unknown)._tag_name.str(),
},
}
},
.svg => return self.subtype(Svg)._tag_name.str(),
}
}
pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
return switch (self._type) {
.html => blk: {
const he = self.subtype(Html);
break :blk switch (he._type) {
.anchor => "A",
.area => "AREA",
.base => "BASE",
.body => "BODY",
.br => "BR",
.button => "BUTTON",
.canvas => "CANVAS",
.custom => upperTagName(&he.subtype(Html.Custom)._tag_name, buf),
.data => "DATA",
.datalist => "DATALIST",
.details => "DETAILS",
.dialog => "DIALOG",
.directory => "DIR",
.div => "DIV",
.dl => "DL",
.embed => "EMBED",
.fieldset => "FIELDSET",
.font => "FONT",
.frameset => "FRAMESET",
.form => "FORM",
.generic => upperTagName(&he.subtype(Html.Generic)._tag_name, buf),
.heading => upperTagName(&he.subtype(Html.Heading)._tag_name, buf),
.head => "HEAD",
.html => "HTML",
.hr => "HR",
.iframe => "IFRAME",
.img => "IMG",
.input => "INPUT",
.label => "LABEL",
.legend => "LEGEND",
.li => "LI",
.link => "LINK",
.map => "MAP",
.marquee => "MARQUEE",
.meta => "META",
.media => switch (he.subtype(Html.Media)._type) {
.audio => "AUDIO",
.video => "VIDEO",
.generic => "MEDIA",
},
.meter => "METER",
.mod => upperTagName(&he.subtype(Html.Mod)._tag_name, buf),
.object => "OBJECT",
.ol => "OL",
.optgroup => "OPTGROUP",
.option => "OPTION",
.output => "OUTPUT",
.p => "P",
.picture => "PICTURE",
.param => "PARAM",
.pre => "PRE",
.progress => "PROGRESS",
.quote => upperTagName(&he.subtype(Html.Quote)._tag_name, buf),
.script => "SCRIPT",
.select => "SELECT",
.slot => "SLOT",
.source => "SOURCE",
.span => "SPAN",
.style => "STYLE",
.table => "TABLE",
.table_caption => "CAPTION",
.table_cell => upperTagName(&he.subtype(Html.TableCell)._tag_name, buf),
.table_col => upperTagName(&he.subtype(Html.TableCol)._tag_name, buf),
.table_row => "TR",
.table_section => upperTagName(&he.subtype(Html.TableSection)._tag_name, buf),
.template => "TEMPLATE",
.textarea => "TEXTAREA",
.time => "TIME",
.title => "TITLE",
.track => "TRACK",
.ul => "UL",
.unknown => switch (self._namespace) {
.html => upperTagName(&he.subtype(Html.Unknown)._tag_name, buf),
.svg, .xml, .mathml, .unknown, .null => he.subtype(Html.Unknown)._tag_name.str(),
},
};
},
.svg => self.subtype(Svg)._tag_name.str(),
};
}
pub fn getTagNameDump(self: *const Element) []const u8 {
switch (self._type) {
.html => return self.getTagNameLower(),
.svg => return self.subtype(Svg)._tag_name.str(),
}
}
pub fn getNamespaceURI(self: *const Element) ?[]const u8 {
return self._namespace.toUri();
}
pub fn getNamespaceUri(self: *Element, frame: *Frame) ?[]const u8 {
if (self._namespace != .unknown) return self._namespace.toUri();
return frame.page.element_namespace_uris.get(self);
}
pub fn lookupNamespaceURIForElement(self: *Element, prefix: ?[]const u8, frame: *Frame) ?[]const u8 {
// Hardcoded reserved prefixes
if (prefix) |p| {
if (std.mem.eql(u8, p, "xml")) return "http://www.w3.org/XML/1998/namespace";
if (std.mem.eql(u8, p, "xmlns")) return "http://www.w3.org/2000/xmlns/";
}
// Step 1: check element's own namespace/prefix
if (self.getNamespaceUri(frame)) |ns_uri| {
const el_prefix = self._prefix();
const match = if (prefix == null and el_prefix == null)
true
else if (prefix != null and el_prefix != null)
std.mem.eql(u8, prefix.?, el_prefix.?)
else
false;
if (match) return ns_uri;
}
// Step 2: search xmlns attributes
for (self._attributes.entries()) |*entry| {
if (prefix == null) {
if (std.mem.eql(u8, entry.name(), "xmlns")) {
const val = entry.value();
return if (val.len == 0) null else val;
}
} else {
const name = entry.name();
if (std.mem.startsWith(u8, name, "xmlns:")) {
if (std.mem.eql(u8, name["xmlns:".len..], prefix.?)) {
const val = entry.value();
return if (val.len == 0) null else val;
}
}
}
}
// Step 3: recurse to parent element
const parent = self.asNode().parentElement() orelse return null;
return parent.lookupNamespaceURIForElement(prefix, frame);
}
// Locate a namespace prefix: the inverse of lookupNamespaceURIForElement.
// Given a namespace URI, find the prefix that declares it.
pub fn lookupPrefixForElement(self: *Element, namespace: []const u8, frame: *Frame) ?[]const u8 {
// Step 1: element's own namespace/prefix
if (self.getNamespaceUri(frame)) |ns_uri| {
if (self._prefix()) |el_prefix| {
if (std.mem.eql(u8, ns_uri, namespace)) {
return el_prefix;
}
}
}
// Step 2: search xmlns: attribute declarations for one whose value is the namespace
for (self._attributes.entries()) |*entry| {
const name = entry.name();
if (std.mem.startsWith(u8, name, "xmlns:") and std.mem.eql(u8, entry.value(), namespace)) {
return name["xmlns:".len..];
}
}
// Step 3: recurse to parent element
const parent = self.asNode().parentElement() orelse return null;
return parent.lookupPrefixForElement(namespace, frame);
}
fn _prefix(self: *const Element) ?[]const u8 {
const name = self.getTagNameLower();
if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| {
return name[0..pos];
}
return null;
}
pub fn getLocalName(self: *Element) []const u8 {
const name = self.getTagNameLower();
if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| {
return name[pos + 1 ..];
}
return name;
}
// Wrapper methods that delegate to Html implementations
pub fn getInnerText(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
const he = self.is(Html) orelse return error.NotHtmlElement;
return he.getInnerText(writer, frame);
}
pub fn setInnerText(self: *Element, text: []const u8, frame: *Frame) !void {
const he = self.is(Html) orelse return error.NotHtmlElement;
return he.setInnerText(text, frame);
}
pub fn insertAdjacentHTML(
self: *Element,
position: []const u8,
html_or_xml: []const u8,
frame: *Frame,
) !void {
const he = self.is(Html) orelse return error.NotHtmlElement;
return he.insertAdjacentHTML(position, html_or_xml, frame);
}
pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, frame);
}
pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const node = self.asNode();
const parent = node._parent orelse return;
// The parent of a documentElement is the Document, which cannot be modified.
if (parent._type == .document) {
return error.NoModificationAllowed;
}
frame.domChanged();
// Observers of the parent must see a single mutation record replacing
// this node with the parsed nodes.
const notify = Frame.observers.hasMutationObservers(frame);
var added: std.ArrayList(*Node) = .empty;
var fragment: ?*Node = null;
if (html.len > 0) {
const frag = (try Node.DocumentFragment.init(node.getDocument(frame), frame)).asNode();
// The parent is the parse context (a fragment parent means body).
try Frame.parse.fragment(frame, frag, html, .{ .context = parent.is(Element) });
fragment = frag;
}
// Parsing (and each insertion below) can synchronously run a custom
// element constructor that mutates the live tree; per the spec's replace
// step, a node that is no longer our parent's child cannot be replaced.
if (node._parent != parent) {
return error.NotFound;
}
// Captured after the parse: a constructor may have reshuffled siblings.
const previous_sibling = node.previousSibling();
const next_sibling = node.nextSibling();
if (fragment) |frag| {
var it = frag.childrenIterator();
while (it.next()) |child| {
if (node._parent != parent) {
return error.NotFound;
}
if (notify) {
try added.append(frame.call_arena, child);
}
frame.removeNode(frag, child, .{ .reconnect_to = parent, .notify_observers = false });
try frame.insertNodeRelative(parent, child, .{ .before = node }, .{ .notify_observers = false });
}
}
if (node._parent != parent) {
return error.NotFound;
}
frame.removeNode(parent, node, .{ .reconnect_to = null, .notify_observers = false });
if (notify) {
const removed = [_]*Node{node};
Frame.observers.notifyChildListChange(frame, parent, added.items, &removed, previous_sibling, next_sibling);
}
}
pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
return dump.children(self.asNode(), .{ .shadow = .skip }, writer, frame);
}
pub fn getHTML(self: *Element, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
return dump.getHTML(self.asNode(), opts, writer, frame);
}
pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
return parent.setHTML(html, .{}, frame);
}
/// allows declarative shadow dom
pub fn setHTMLUnsafe(self: *Element, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame);
}
pub fn getId(self: *const Element) ?[]const u8 {
return self.getAttributeInterned("id");
}
pub fn setId(self: *Element, value: []const u8, frame: *Frame) !void {
return self.setAttributeSafe(comptime .wrap("id"), .wrap(value), frame);
}
// ** INTERN ONL **. Unlike other getters, e.g. getClassName, getSlot, this
// isn't a WebApi (some individual types DO have a name getter, but not Element).
// BUT, enough code internally needs this, that the helper exists.
pub fn getName(self: *const Element) ?[]const u8 {
return self.getAttributeInterned("name");
}
pub fn hasName(self: *const Element) bool {
return self.hasAttributeInterned("name");
}
pub fn getSlot(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(comptime .wrap("slot"));
}
pub fn setSlot(self: *Element, value: []const u8, frame: *Frame) !void {
return self.setAttributeSafe(comptime .wrap("slot"), .wrap(value), frame);
}
pub fn getDir(self: *const Element) ?[]const u8 {
return self.getAttributeInterned("dir");
}
pub fn setDir(self: *Element, value: []const u8, frame: *Frame) !void {
return self.setAttributeSafe(comptime .wrap("dir"), .wrap(value), frame);
}
pub fn getClassName(self: *const Element) ?[]const u8 {
return self.getAttributeInterned("class");
}
pub fn setClassName(self: *Element, value: []const u8, frame: *Frame) !void {
return self.setAttributeSafe(comptime .wrap("class"), .wrap(value), frame);
}
/// DANGER: Invalidated by mutation of the attribute list.
pub fn attributeEntries(self: *const Element) []const Attribute.List.Entry {
return self._attributes.entries();
}
pub fn getAttribute(self: *const Element, name: String, frame: *Frame) !?String {
return self._attributes.get(name, frame);
}
pub fn getAttributeNS(
self: *const Element,
namespace_: ?[]const u8,
local_name: String,
frame: *Frame,
) !?String {
if (namespace_) |namespace| {
// we don't really support namespaces, but if the namespace has a fixed
// prefix, we can try to fetch the attribute with it
if (try prefixedAttributeName(namespace, local_name.str(), frame)) |prefixed| {
if (try self.getAttribute(.wrap(prefixed), frame)) |value| {
return value;
}
}
}
return self.getAttribute(local_name, frame);
}
fn prefixedAttributeName(namespace: []const u8, local_name: []const u8, frame: *Frame) !?[]const u8 {
const prefix = blk: {
if (std.mem.eql(u8, namespace, "http://www.w3.org/1999/xlink")) {
break :blk "xlink";
}
if (std.mem.eql(u8, namespace, "http://www.w3.org/XML/1998/namespace")) {
break :blk "xml";
}
if (std.mem.eql(u8, namespace, "http://www.w3.org/2000/xmlns/")) {
break :blk "xmlns";
}
return null;
};
return try std.fmt.allocPrint(frame.local_arena, "{s}:{s}", .{ prefix, local_name });
}
pub fn getAttributeSafe(self: *const Element, name: String) ?[]const u8 {
return self._attributes.getSafe(name);
}
pub fn hasAttribute(self: *const Element, name: String, frame: *Frame) !bool {
const value = try self._attributes.get(name, frame);
return value != null;
}
pub fn hasAttributeNS(
self: *const Element,
namespace_: ?[]const u8,
local_name: String,
frame: *Frame,
) !bool {
return try self.getAttributeNS(namespace_, local_name, frame) != null;
}
pub fn hasAttributeSafe(self: *const Element, name: String) bool {
return self._attributes.hasSafe(name);
}
// Like getAttributeSafe, but faster! Only usable for values that are String.intern
// so that the comparison becomes a single pointer equality.
pub fn getAttributeInterned(self: *const Element, comptime name: []const u8) ?[]const u8 {
return self._attributes.getInterned(name);
}
pub fn hasAttributeInterned(self: *const Element, comptime name: []const u8) bool {
return self._attributes.hasInterned(name);
}
// Per HTML "concept-fe-disabled", only listed elements participate in the
// disabled concept. Anything else (e.g. <div disabled>) has no disabled
// state and never matches :disabled / :enabled.
pub fn hasDisabledConcept(self: *const Element) bool {
return switch (self.getTag()) {
.button, .input, .select, .textarea, .optgroup, .option, .fieldset => true,
else => false,
};
}
pub fn isDisabled(self: *const Element) bool {
if (!self.hasDisabledConcept()) {
return false;
}
if (self.getAttributeInterned("disabled") != null) {
return true;
}
// <option> takes a different inheritance path: per HTML
// "concept-option-disabled" an option is disabled when its parent is an
// <optgroup disabled>. It does NOT inherit from <select disabled> or
// an ancestor <fieldset disabled>.
if (self.getTag() == .option) {
if (self.asConstNode()._parent) |parent_node| {
if (parent_node.is(Element)) |parent_el| {
if (parent_el.getTag() == .optgroup and
parent_el.getAttributeInterned("disabled") != null)
{
return true;
}
}
}
return false;
}
const element_node = self.asConstNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeInterned("disabled") != null) {
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
pub fn hasAttributes(self: *const Element) bool {
return self._attributes.isEmpty() == false;
}
pub fn getAttributeNode(self: *Element, name: String, frame: *Frame) !?*Attribute {
return self._attributes.getAttribute(name, self, frame);
}
pub fn setAttribute(self: *Element, name: String, value: String, frame: *Frame) !void {
try Attribute.validateAttributeName(name);
_ = try self._attributes.put(name, value, self, frame);
}
pub fn setAttributeNS(
self: *Element,
namespace_: ?[]const u8,
qualified_name: []const u8,
value: String,
frame: *Frame,
) !void {
const local_start = if (std.mem.indexOfScalarPos(u8, qualified_name, 0, ':')) |idx| blk: {
if (idx == 0 or idx == qualified_name.len - 1) {
// cannot be at the start or end of the qname
return error.InvalidCharacterError;
}
if (std.mem.indexOfScalarPos(u8, qualified_name, idx + 1, ':') != null) {
// and can only have one
return error.InvalidCharacterError;
}
break :blk idx + 1;
} else 0;
const attr_name = if (namespace_ != null) qualified_name else qualified_name[local_start..];
return self.setAttribute(.wrap(attr_name), value, frame);
}
pub fn setAttributeSafe(self: *Element, name: String, value: String, frame: *Frame) !void {
_ = try self._attributes.putSafe(name, value, self, frame);
}
pub fn getShadowRoot(self: *Element, frame: *Frame) ?*ShadowRoot {
const shadow_root = self.hostedShadowRoot(frame) orelse return null;
if (shadow_root._mode == .closed) return null;
return shadow_root;
}
pub fn getAssignedSlot(self: *Element, frame: *Frame) ?*Html.Slot {
// Hidden by a closed shadow tree
return slotting.findSlot(self.asNode(), true, frame);
}
// Whether this element may host a shadow root
fn isValidShadowHost(self: *const Element) bool {
if (self._namespace != .html) {
return false;
}
return switch (self.getTag()) {
.article, .aside, .blockquote, .body, .div, .footer, .header, .main, .nav, .p, .section, .span, .h1, .h2, .h3, .h4, .h5, .h6, .custom => true,
else => false,
};
}
pub fn attachShadow(self: *Element, opts: ShadowRoot.AttachOptions, frame: *Frame) !*ShadowRoot {
if (!self.isValidShadowHost()) {
return error.NotSupported;
}
// A custom element whose definition lists "shadow" in disabledFeatures
// cannot host a shadow root (imperative or declarative).
if (self.is(Html.Custom)) |custom| {
if (frame.window._custom_elements._definitions.get(custom._tag_name.str())) |def| {
if (def.disable_shadow) {
return error.NotSupported;
}
}
}
if (self.hostedShadowRoot(frame)) |existing| {
// Imperative attachShadow over a declarative shadow root with a matching
// mode empties it and returns the same root. The parser
// (opts.declarative) never replaces an existing root.
if (opts.declarative or !existing._declarative or existing._mode != opts.mode) {
return error.NotSupported;
}
try existing.asNode().replaceChildren(&.{}, frame);
existing._declarative = false;
return existing;
}
const shadow_root = try ShadowRoot.init(self, opts, frame);
const page = frame.page;
try page.element_shadow_roots.put(page.frame_arena, self, shadow_root);
self._flags.shadow_host = true;
return shadow_root;
}
// The shadow root this element hosts, closed ones included (the JS-facing
// getShadowRoot filters those). The flag check skips the map probe for the
// overwhelming majority of elements, which host nothing.
pub fn hostedShadowRoot(self: *Element, frame: *const Frame) ?*ShadowRoot {
if (!self._flags.shadow_host) {
return null;
}
return frame.page.element_shadow_roots.get(self);
}
pub fn insertAdjacentElement(
self: *Element,
position: []const u8,
element: *Element,
frame: *Frame,
) !?*Element {
const target_node, const prev_node = self.asNode().findAdjacentNodes(position, .node) catch |err| switch (err) {
// beforebegin/afterend with no parent is a no-op returning null.
error.AdjacentNoParent => return null,
else => return err,
};
_ = try target_node.insertBefore(element.asNode(), prev_node, frame);
return element;
}
pub fn insertAdjacentText(
self: *Element,
where: []const u8,
data: []const u8,
frame: *Frame,
) !void {
const target_node, const prev_node = self.asNode().findAdjacentNodes(where, .node) catch |err| switch (err) {
// beforebegin/afterend with no parent is a no-op.
error.AdjacentNoParent => return,
else => return err,
};
const text_node = try Frame.node_factory.createTextNode(self.getDocument(frame), data);
_ = try target_node.insertBefore(text_node, prev_node, frame);
}
pub fn setAttributeNode(self: *Element, attr: *Attribute, frame: *Frame) !?*Attribute {
if (attr._element) |el| {
if (el == self) {
return attr;
}
return error.InUseAttribute;
}
return self._attributes.putAttribute(attr, self, frame);
}
pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void {
return self._attributes.delete(name, self, frame);
}
pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void {
self._attributes.deleteSafe(name, self, frame);
}
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, frame);
const should_add = force orelse !has;
if (should_add and !has) {
try self.setAttribute(name, String.empty, frame);
return true;
} else if (!should_add and has) {
try self.removeAttribute(name, frame);
return false;
}
return should_add;
}
pub fn removeAttributeNode(self: *Element, attr: *Attribute, frame: *Frame) !*Attribute {
if (attr._element == null or attr._element.? != self) {
return error.NotFound;
}
try self.removeAttribute(attr._name, frame);
attr._element = null;
return attr;
}
pub fn getAttributeNames(self: *const Element, frame: *Frame) ![][]const u8 {
return self._attributes.getNames(frame.local_arena);
}
pub fn getAttributeNamedNodeMap(self: *Element, frame: *Frame) !*Attribute.NamedNodeMap {
const page = frame.page;
const gop = try page.attribute_named_node_map_lookup.getOrPut(page.frame_arena, @intFromPtr(self));
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(Attribute.NamedNodeMap{ ._element = self });
}
return gop.value_ptr.*;
}
// The materialized style is built with the element's own frame, not the
// caller's: a same-origin script can reach an element in another frame.
pub fn getOrCreateStyle(self: *Element, frame: *Frame) !*CSSStyleProperties {
const owner = self.ownerFrame(frame) orelse frame;
const page = frame.page;
const gop = try page.element_styles.getOrPut(page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try CSSStyleProperties.init(self, false, owner);
}
self._flags.has_inline_style = true;
return gop.value_ptr.*;
}
pub fn existingStyle(self: *Element, frame: *Frame) ?*CSSStyleProperties {
if (!self._flags.has_inline_style) {
return null;
}
return frame.page.element_styles.get(self);
}
/// The inline style object, parsed from the style attribute on first use;
/// null when the element has neither.
pub fn inlineStyle(self: *Element, frame: *Frame) ?*CSSStyleProperties {
if (!self._flags.has_inline_style) {
return null;
}
if (self.existingStyle(frame)) |style| {
return style;
}
if (self.getAttributeInterned("style") == null) {
return null;
}
return self.getOrCreateStyle(frame) catch |err| {
log.err(.browser, "inline style parse", .{ .err = err });
return null;
};
}
// Marks the element as possibly having inline style once a `style` attribute
// lands on it. Attribute population paths that bypass attributeChange (the
// parser, cloneNode) call this after filling the list.
pub fn noteStyleAttribute(self: *Element) void {
if (self._attributes.hasSafe(comptime .wrap("style"))) {
self._flags.has_inline_style = true;
}
}
pub fn setStyle(self: *Element, value: []const u8, frame: *Frame) !void {
const style = try self.getOrCreateStyle(frame);
try style.asCSSStyleDeclaration().setCssText(value, frame);
}
pub fn getClassList(self: *Element, frame: *Frame) !*collections.DOMTokenList {
const page = frame.page;
const gop = try page.element_class_lists.getOrPut(page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = comptime .wrap("class"),
});
}
return gop.value_ptr.*;
}
pub fn setClassList(self: *Element, value: String, frame: *Frame) !void {
const class_list = try self.getClassList(frame);
try class_list.setValue(value, frame);
}
pub fn getPartList(self: *Element, frame: *Frame) !*collections.DOMTokenList {
const page = frame.page;
const gop = try page.element_part_lists.getOrPut(page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = comptime .wrap("part"),
});
}
return gop.value_ptr.*;
}
pub fn getRelList(self: *Element, frame: *Frame) !*collections.DOMTokenList {
const page = frame.page;
const gop = try page.element_rel_lists.getOrPut(page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = comptime .wrap("rel"),
});
}
return gop.value_ptr.*;
}
// The other DOMTokenList-reflected attributes (class and rel have dedicated
// lookups above).
pub const TokenListAttribute = enum { sizes, sandbox, @"for" };
pub const TokenListKey = struct { element: *Element, attribute: TokenListAttribute };
pub const TokenListLookup = std.AutoHashMapUnmanaged(TokenListKey, *collections.DOMTokenList);
pub fn getTokenList(self: *Element, comptime attribute: TokenListAttribute, frame: *Frame) !*collections.DOMTokenList {
const page = frame.page;
const gop = try page.element_token_lists.getOrPut(page.frame_arena, .{ .element = self, .attribute = attribute });
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = comptime .wrap(@tagName(attribute)),
});
}
return gop.value_ptr.*;
}
pub fn getDataset(self: *Element, frame: *Frame) !*DOMStringMap {
const page = frame.page;
const gop = try page.element_datasets.getOrPut(page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try frame._factory.create(DOMStringMap{
._element = self,
});
}
return gop.value_ptr.*;
}
pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
return self.asNode().replaceChildren(nodes, frame);
}
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const ref_node = self.asNode();
const parent = ref_node._parent orelse return;
frame.domChanged();
// Detect if the ref_node must be removed (by default) or kept.
// We kept it when ref_node is present into the nodes list.
var rm_ref_node = true;
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(self.getDocument(frame));
// If a child is the ref node. We keep it at its own current position.
if (child == ref_node) {
rm_ref_node = false;
continue;
}
// A DocumentFragment contributes its children, not itself
if (child.is(Node.DocumentFragment)) |_| {
try frame.insertAllChildrenBefore(child, parent, ref_node);
continue;
}
var previous_root: ?*Node = null;
if (child._parent) |current_parent| {
previous_root = child.getRootNode(.{});
frame.removeNode(current_parent, child, .{ .reconnect_to = parent });
}
try frame.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .previous_root = previous_root },
);
}
// Re-check parent after insertNodeRelative since callbacks (e.g. connectedCallback)
// could have already removed ref_node from parent.
if (rm_ref_node and ref_node._parent == parent) {
frame.removeNode(parent, ref_node, .{ .reconnect_to = null });
}
}
pub fn remove(self: *Element, frame: *Frame) void {
const node = self.asNode();
const parent = node._parent orelse return;
frame.domChanged();
frame.removeNode(parent, node, .{ .reconnect_to = null });
}
// SVG 2 <a> links via `href`; xlink:href is the deprecated SVG 1.1 spelling.
pub fn svgAnchorHref(self: *Element) ?[]const u8 {
return self.getAttributeInterned("href") orelse self.getAttributeSafe(comptime .wrap("xlink:href"));
}
pub fn isSvgLink(self: *Element) bool {
return self.is(Svg.Graphics.A) != null and self.svgAnchorHref() != null;
}
// An editing host takes focus like a form control does.
pub fn isEditingHost(self: *Element) bool {
const value = self.getAttributeSafe(.wrap("contenteditable")) orelse return false;
return std.ascii.eqlIgnoreCase(value, "false") == false;
}
/// Focusable without a tabindex attribute.
fn isNativelyFocusable(self: *Element) bool {
if (self.is(Html) == null) {
return self.isSvgLink();
}
return switch (self.getTag()) {
.button, .select, .textarea, .iframe => true,
.input => self.as(Html.Input)._input_type != .hidden,
.anchor, .area => self.getAttributeInterned("href") != null,
else => false,
};
}
// The tabindex of a focusable area, or null when the element can't take focus
// at all. A negative value is still focusable, just skipped by sequential
// focus navigation.
// https://html.spec.whatwg.org/multipage/interaction.html#focusable-area
pub fn focusTabIndex(self: *Element) ?i32 {
if (self.isDisabled()) {
return null;
}
if (self.getAttributeInterned("tabindex")) |attr| {
if (Html.parseInteger(attr)) |tab_index| {
return tab_index;
} else {
// can't be parsed is treated the same as no tabindex
}
}
if (self.isNativelyFocusable() or self.isEditingHost()) {
return 0;
}
return null;
}
// A focusable area that can take focus right now: connected, not inert and
// being rendered.
pub fn isFocusable(self: *Element, frame: *Frame) bool {
if (self.focusTabIndex() == null) {
return false;
}
const node = self.asNode();
if (node.isConnected() == false) {
return false;
}
if (node.isInert(frame)) {
return false;
}
return self.isVisible(frame);
}
pub fn focus(self: *Element, frame: *Frame) !void {
if (self.asNode().isConnected() == false) {
// a disconnected node cannot take focus
return;
}
const owner = self.ownerFrame(frame) orelse return;
const doc = owner.document;
const old_active = doc._active_element;
if (old_active == self) {
return;
}
if (self.isFocusable(owner) == false) {
return;
}
const FocusEvent = @import("event/FocusEvent.zig");
const new_target = self.asEventTarget();
doc.setActiveElement(self, owner);
if (old_active) |old| {
const old_target = old.asEventTarget();
// Dispatch blur on old element (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true, .relatedTarget = new_target }, owner);
try owner._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout on old element (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true, .relatedTarget = new_target }, owner);
try owner._event_manager.dispatch(old_target, focusout_event.asEvent());
}
const old_related: ?*EventTarget = if (old_active) |old| old.asEventTarget() else null;
// Dispatch focus on new element (no bubble, composed)
const focus_event = try FocusEvent.initTrusted(comptime .wrap("focus"), .{ .composed = true, .relatedTarget = old_related }, owner);
try owner._event_manager.dispatch(new_target, focus_event.asEvent());
// Dispatch focusin on new element (bubbles, composed)
const focusin_event = try FocusEvent.initTrusted(comptime .wrap("focusin"), .{ .bubbles = true, .composed = true, .relatedTarget = old_related }, owner);
try owner._event_manager.dispatch(new_target, focusin_event.asEvent());
}
pub fn blur(self: *Element, frame: *Frame) !void {
// A frameless document never has a focused element.
const owner = self.ownerFrame(frame) orelse return;
const doc = owner.document;
if (doc._active_element != self) return;
doc.setActiveElement(null, owner);
const FocusEvent = @import("event/FocusEvent.zig");
const old_target = self.asEventTarget();
// Dispatch blur (no bubble, composed)
const blur_event = try FocusEvent.initTrusted(comptime .wrap("blur"), .{ .composed = true }, owner);
try owner._event_manager.dispatch(old_target, blur_event.asEvent());
// Dispatch focusout (bubbles, composed)
const focusout_event = try FocusEvent.initTrusted(comptime .wrap("focusout"), .{ .bubbles = true, .composed = true }, owner);
try owner._event_manager.dispatch(old_target, focusout_event.asEvent());
}
pub fn getChildren(self: *Element, frame: *Frame) !collections.NodeLive(.child_elements) {
return collections.NodeLive(.child_elements).init(self.asNode(), {}, frame);
}
pub fn append(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
return self.asNode().appendNodes(nodes, frame);
}
pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
return self.asNode().prependNodes(nodes, frame);
}
pub fn moveBefore(self: *Element, node: js.Value, child: js.Value, frame: *Frame) !void {
return self.asNode().moveBefore(node, child, frame);
}
pub fn before(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const node = self.asNode();
const parent = node.parentNode() orelse return;
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(self.getDocument(frame));
_ = try parent.insertBefore(child, node, frame);
}
}
pub fn after(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const node = self.asNode();
const parent = node.parentNode() orelse return;
const viable_next = Node.NodeOrText.viableNextSibling(node, nodes);
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(self.getDocument(frame));
_ = try parent.insertBefore(child, viable_next, frame);
}
}
pub fn firstElementChild(self: *Element) ?*Element {
var maybe_child = self.asNode().firstChild();
while (maybe_child) |child| {
if (child.is(Element)) |el| return el;
maybe_child = child.nextSibling();
}
return null;
}
pub fn lastElementChild(self: *Element) ?*Element {
var maybe_child = self.asNode().lastChild();
while (maybe_child) |child| {
if (child.is(Element)) |el| return el;
maybe_child = child.previousSibling();
}
return null;
}
pub fn nextElementSibling(self: *Element) ?*Element {
var maybe_sibling = self.asNode().nextSibling();
while (maybe_sibling) |sibling| {
if (sibling.is(Element)) |el| return el;
maybe_sibling = sibling.nextSibling();
}
return null;
}
pub fn previousElementSibling(self: *Element) ?*Element {
var maybe_sibling = self.asNode().previousSibling();
while (maybe_sibling) |sibling| {
if (sibling.is(Element)) |el| return el;
maybe_sibling = sibling.previousSibling();
}
return null;
}
pub fn getChildElementCount(self: *Element) usize {
var count: usize = 0;
var it = self.asNode().childrenIterator();
while (it.next()) |node| {
if (node.is(Element) != null) {
count += 1;
}
}
return count;
}
pub fn matches(self: *Element, selector: []const u8, frame: *Frame) !bool {
return Selector.matches(self, selector, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelector(self: *Element, selector: []const u8, frame: *Frame) !?*Element {
return Selector.querySelector(self.asNode(), selector, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn querySelectorAll(self: *Element, input: []const u8, frame: *Frame) !*Selector.List {
return Selector.querySelectorAll(self.asNode(), input, frame) catch |err| Selector.mapErrorToDOM(err);
}
pub fn getAnimations(_: *const Element) []*Animation {
return &.{};
}
pub fn animate(_: *Element, _: ?js.Object, _: ?js.Object, frame: *Frame) !*Animation {
return Animation.init(frame);
}
pub fn closest(self: *Element, input: []const u8, frame: *Frame) !?*Element {
if (input.len == 0) {
return error.SyntaxError;
}
const selector = try Selector.cachedParse(frame._session.browser, input);
var current: ?*Element = self;
while (current) |el| {
if (try Selector.matchesWithScope(el, selector, self, frame)) {
return el;
}
const parent = el.asNode()._parent orelse break;
if (parent.is(ShadowRoot) != null) {
break;
}
current = parent.is(Element);
}
return null;
}
pub fn parentElement(self: *Element) ?*Element {
return self.asNode().parentElement();
}
// Style checks go through the StyleManager of the element's own frame, not
// the caller's: its stylesheets and materialized inline styles are per-frame,
// and a same-origin script can reach an element in another frame.
pub fn hasPointerEventsNone(self: *Element, frame: *Frame) bool {
const owner = self.ownerFrame(frame) orelse return false;
return owner._style_manager.hasPointerEventsNone(self);
}
pub fn isVisible(self: *Element, frame: *Frame) bool {
const owner = self.ownerFrame(frame) orelse return false;
return !owner._style_manager.isHidden(self, .{});
}
const CheckVisibilityOpts = struct {
checkOpacity: bool = false,
checkVisibilityCSS: bool = false,
opacityProperty: bool = false,
visibilityProperty: bool = false,
};
pub fn checkVisibility(self: *Element, opts_: ?CheckVisibilityOpts, frame: *Frame) bool {
const opts = opts_ orelse CheckVisibilityOpts{};
const owner = self.ownerFrame(frame) orelse return false;
return !owner._style_manager.isHidden(self, .{
.check_opacity = opts.checkOpacity or opts.opacityProperty,
.check_visibility = opts.visibilityProperty or opts.checkVisibilityCSS,
});
}
pub const Axis = enum {
width,
height,
// The axis' value, and whether it's explicit or the default
pub const State = struct {
value: f64,
explicit: bool = false,
};
};
pub fn getElementAxis(self: *Element, frame: *Frame, comptime axis: Axis) Axis.State {
const tag = self.getTag();
const root = tag == .html or tag == .body;
if (self.ownerFrame(frame)) |owner| {
const style_manager = &owner._style_manager;
// Roots take only an inline size: a sheet's `height: 100vh` on body
// would shrink the box every synthetic position must fit in.
const size = if (root) style_manager.inlineSize(self, axis) else style_manager.declaredSize(self, axis);
if (size) |v| {
return .{ .value = v, .explicit = true };
}
}
// Root containers span the document, see Document.extent.
if (root) {
return .{ .value = switch (axis) {
.width => 1920.0,
.height => if (self.asNode().ownerDocument(frame)) |doc| doc.extent().height else 0.0,
} };
}
// Presentational attributes lose to CSS sizes.
if (tag == .img or tag == .iframe) {
if (self.getAttributeSafe(comptime .wrap(@tagName(axis)))) |attr| {
if (std.fmt.parseFloat(f64, attr)) |parsed| {
return .{ .value = parsed, .explicit = true };
} else |_| {}
}
}
return .{ .value = 5.0 };
}
// We can't do this correctly without full styles and more rendering. We also
// can't just ignore the children since some sites append nodes until a certain
// width / height treshold is reached. If the size isn't explicit, we fallback
// to the content size.
pub fn getClientWidth(self: *Element, frame: *Frame) f64 {
return self.clientAxis(frame, .width);
}
pub fn getClientHeight(self: *Element, frame: *Frame) f64 {
return self.clientAxis(frame, .height);
}
fn clientAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
return self.viewportAxis(frame, axis) orelse self.boxAxis(frame, axis);
}
/// Document.scrollSize, when self is the root scroller (see viewportAxis).
fn rootScrollSize(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
_ = self.viewportAxis(frame, axis) orelse return null;
const doc = self.asNode().ownerDocument(frame) orelse return null;
return @field(doc.scrollSize(), @tagName(axis));
}
fn viewportAxis(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
const tag = self.getTag();
if (tag != .html and tag != .body) {
return null;
}
const doc = self.asNode().ownerDocument(frame) orelse frame.document;
if ((tag == .body) != doc.isQuirksMode()) {
return null;
}
// In quicks mode, the root element (the body) reports the viewport for
// clientWidth and clientHeight rather than its own MASSIVE box. This
// fixes jstracker's uiContourMap which attempts to tile the clientHeight
// of the body. (https://github.com/lightpanda-io/browser/issues/3251)
const viewport = frame.page.getViewport();
return @floatFromInt(if (axis == .width) viewport.width else viewport.height);
}
// Caller must have made sure self is visible.
pub fn boxAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
const own = self.getElementAxis(frame, axis);
if (own.explicit) {
// an explicitly set value always wins
return own.value;
}
const tag = self.getTag();
if (tag == .html or tag == .body) {
// html/body return their set value regardless of children.
return own.value;
}
return @max(own.value, self.contentAxis(frame, axis));
}
pub fn getBoundingClientRect(self: *Element, frame: *Frame) !*DOMRect {
return DOMRect.create(self.boundingClientRectValues(frame), frame._factory);
}
// Plain rect values, no JS-backed allocation: the internal fast path shared by
// getBoundingClientRect, getClientRects, and IntersectionObserver. A DOMRect is
// only materialized at the JS boundary.
pub fn boundingClientRectValues(self: *Element, frame: *Frame) DOMRect.Data {
if (!self.isVisible(frame)) {
return .{};
}
return self.boundingClientRectValuesForVisible(frame);
}
// Some cases need the bounding rect but have already done the visibility check.
pub fn boundingClientRectValuesForVisible(self: *Element, frame: *Frame) DOMRect.Data {
return .{
.x = self.horizontalPosition(frame),
.y = calculateDocumentPosition(self.asNode()),
.width = self.boxAxis(frame, .width),
.height = self.boxAxis(frame, .height),
};
}
pub fn getClientRects(self: *Element, frame: *Frame) ![]*DOMRect {
if (!self.isVisible(frame)) {
return &.{};
}
const rects = try frame.local_arena.alloc(*DOMRect, 1);
rects[0] = try DOMRect.create(self.boundingClientRectValuesForVisible(frame), frame._factory);
return rects;
}
// Scroll events fire in the element's own frame — not the caller's, which
// differs when a same-origin script scrolls an element in another frame (e.g.
// inside an iframe). All scroll accessors resolve the owner frame first so the
// fired events and the document comparison stay in the element's frame.
pub fn getScrollTop(self: *Element, frame: *Frame) u32 {
const owner = self.ownerFrame(frame) orelse return 0;
const pos = owner.page.element_scroll_positions.get(self) orelse return 0;
return pos.y;
}
pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void {
_ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame);
}
pub fn getScrollLeft(self: *Element, frame: *Frame) u32 {
const owner = self.ownerFrame(frame) orelse return 0;
const pos = owner.page.element_scroll_positions.get(self) orelse return 0;
return pos.x;
}
pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
_ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame);
}
pub const ScrollAxes = struct { x: bool = false, y: bool = false };
/// What a scroll along one axis lands on. html and body scroll the viewport,
/// and so does a detached element.
const ScrollTarget = union(enum) {
viewport,
container: *Element,
};
/// Nearest ancestor-or-self scroll container along any of `axes`. The walk
/// is cheap to repeat per axis: the cascade memoizes each element's props.
pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTarget {
if (!axes.x and !axes.y) return .viewport;
const owner = self.ownerFrame(frame) orelse return .viewport;
const style_manager = &owner._style_manager;
var current: ?*Element = self;
while (current) |el| : (current = el.parentElement()) {
if (el.scrollsViewport()) break;
const scrolls = style_manager.overflowAxes(el);
if ((axes.x and scrolls.x) or (axes.y and scrolls.y)) {
return .{ .container = el };
}
}
return .viewport;
}
/// Whether the element's own overscroll-behavior keeps a scroll from chaining
/// out of it.
pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool {
const owner = self.ownerFrame(frame) orelse return false;
const contains = owner._style_manager.overscrollContainAxes(self);
return (axes.x and contains.x) or (axes.y and contains.y);
}
fn scrollsViewport(self: *const Element) bool {
return switch (self.getTag()) {
.html, .body => true,
else => false,
};
}
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
const height = self.getElementAxis(frame, .height).value;
const tag = self.getTag();
// The root scroller reports what the viewport scrolls over.
if (tag == .html or tag == .body) {
return self.rootScrollSize(frame, .height) orelse height;
}
return @max(height, self.contentAxis(frame, .height));
}
pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
const width = self.getElementAxis(frame, .width).value;
const tag = self.getTag();
// Roots don't sum their children side by side. The root scroller
// reports what the viewport scrolls over.
if (tag == .html or tag == .body) {
return self.rootScrollSize(frame, .width) orelse width;
}
return @max(width, self.contentAxis(frame, .width));
}
/// Null where we can't prove a limit, which leaves the offset unbounded:
/// without an explicit size the client and content measurements collapse onto
/// the same sum. html and body scroll the viewport, clamped by Window.
fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 {
if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) {
return null;
}
const client = self.clientAxis(frame, axis);
const content = switch (axis) {
.width => self.getScrollWidth(frame),
.height => self.getScrollHeight(frame),
};
if (content <= client) {
return null;
}
return content - client;
}
// One axis of the direct child elements' size: laid end to end on a single
// row for the width, stacked for the height.
//
// The dummy layout engine has no line-breaking, and an element only overflows
// horizontally when its children don't wrap (white-space:nowrap, a flex row, an
// inline-block strip), so the single-row assumption covers the case that
// matters. We can't detect the layout mode to do better: existingStyle() sees only
// the inline `style=` attribute, and the computed cascade resolves stylesheet
// rules for `display:none` and `visibility` alone.
//
// Only direct children are measured, never the whole subtree. This runs on
// every size read, and recursing would make an element's cost O(subtree)
// rather than O(fan-out). It also keeps an ancestor from growing in lockstep
// with its descendants, so "append until the track outgrows its shell" still
// crosses the threshold.
//
// Growing with the child count is the point: JS that appends content until
// `scrollWidth` passes a threshold (the infinite-marquee idiom) never
// terminates when the metric ignores what it just inserted.
//
// Text children add height only under an explicit width to wrap at.
// Otherwise almost every element with text would report overflow.
pub fn contentAxis(self: *Element, frame: *Frame, comptime axis: Axis) f64 {
var total: f64 = 0;
const owner = self.ownerFrame(frame) orelse return 0;
const style_manager = &owner._style_manager;
var wrap: ?text_measure.LineWrap = null;
if (axis == .height) {
const width = self.getElementAxis(frame, .width);
if (width.explicit) {
wrap = .{ .line_width = width.value, .font_size = style_manager.computedFontSize(self) };
}
}
var child = self.asNode().firstChild();
while (child) |node| : (child = node.nextSibling()) {
if (node.is(Element)) |el| {
if (!style_manager.hasDisplayNone(el)) {
total += el.getElementAxis(frame, axis).value;
}
} else if (wrap) |*w| {
if (node.is(Node.CData.Text)) |text| {
w.add(text.ownData());
}
}
}
if (wrap) |w| {
// Whole pixels, like scroll offsets
total += @ceil(w.height());
}
return total;
}
// Unlike clientHeight, the root's offsetHeight is the document height.
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
return self.boxAxis(frame, .height);
}
pub fn getOffsetWidth(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
return self.boxAxis(frame, .width);
}
pub fn getOffsetTop(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
return calculateDocumentPosition(self.asNode());
}
pub fn getOffsetLeft(self: *Element, frame: *Frame) f64 {
if (!self.isVisible(frame)) {
return 0.0;
}
return self.horizontalPosition(frame);
}
pub fn getOffsetParent(self: *Element, frame: *Frame) ?*Element {
if (!self.asNode().isConnected() or !self.isVisible(frame)) {
return null;
}
switch (self.getTag()) {
.html, .body => return null,
else => {},
}
const self_position = self.positionStyle(frame);
if (std.mem.eql(u8, self_position, "fixed")) {
return null;
}
const self_static = self_position.len == 0 or std.mem.eql(u8, self_position, "static");
var node: ?*Node = self.asNode()._parent;
while (node) |n| {
if (n.is(ShadowRoot)) |sr| {
// this always pokes through the shadow dom
node = sr.getHost().asNode();
continue;
}
const ancestor = n.is(Element) orelse break;
const tag = ancestor.getTag();
if (tag == .body) {
return ancestor;
}
const position = ancestor.positionStyle(frame);
if (position.len > 0 and !std.mem.eql(u8, position, "static")) {
return ancestor;
}
if (self_static and (tag == .td or tag == .th or tag == .table)) {
return ancestor;
}
node = n._parent;
}
return null;
}
fn positionStyle(self: *Element, frame: *Frame) []const u8 {
const style = self.inlineStyle(frame) orelse return "";
return style.asCSSStyleDeclaration().getPropertyValue("position", frame);
}
pub fn getClientTop(_: *Element) f64 {
// Border width - in our dummy layout, we don't apply borders to layout
return 0.0;
}
pub fn getClientLeft(_: *Element) f64 {
// Border width - in our dummy layout, we don't apply borders to layout
return 0.0;
}
// Calculates document position by counting all nodes that appear before this one
// in tree order, but only traversing the "left side" of the tree.
//
// This walks up from the target node to the root, and at each level counts:
// 1. All previous siblings and their descendants
// 2. The parent itself
//
// Example:
// <body> → y=0
// <h1>Text</h1> → y=1 (body=1)
// <h2> → y=2 (body=1 + h1=1)
// <a>Link1</a> → y=3 (body=1 + h1=1 + h2=1)
// </h2>
// <p>Text</p> → y=5 (body=1 + h1=1 + h2=2)
// <h2> → y=6 (body=1 + h1=1 + h2=2 + p=1)
// <a>Link2</a> → y=7 (body=1 + h1=1 + h2=2 + p=1 + h2=1)
// </h2>
// </body>
//
// Trade-offs:
// - O(depth × siblings × subtree_height) - only left-side traversal
// - Linear scaling: 5px per node
// - Perfect document order, guaranteed unique positions
// - Compact coordinates (1000 nodes ≈ 5,000px)
fn calculateDocumentPosition(node: *Node) f64 {
var position: f64 = 0.0;
var current = node;
// Walk up to root, counting preceding nodes
while (current.parentNode()) |parent| {
// Count all previous siblings and their descendants
var sibling = parent.firstChild();
while (sibling) |s| {
if (s == current) break;
position += countSubtreeNodes(s);
sibling = s.nextSibling();
}
// Count the parent itself
position += 1.0;
current = parent;
}
return position * 5.0; // 5px per node
}
// Counts total nodes in a subtree (node + all descendants)
fn countSubtreeNodes(node: *Node) f64 {
var count: f64 = 0;
var tw = TreeWalker.Full.init(node, .{});
while (tw.next()) |_| {
count += 1;
}
return count;
}
// The horizontal position follows the same single-row assumption as
// contentAxis: an element sits to the right of the visible element
// siblings before it.
// translateX is commonly used to shift elements around, e.g. in a carousel to
// shift things around. So we honor any transform: translateX inline styles.
pub fn horizontalPosition(self: *Element, frame: *Frame) f64 {
var x: f64 = 0.0;
var current = self.asNode();
const owner = self.ownerFrame(frame) orelse return 0;
const style_manager = &owner._style_manager;
if (self.inlineStyle(frame)) |style| {
x += CSS.parseTranslateX(style.asCSSStyleDeclaration().getPropertyValue("transform", frame));
}
while (current.parentNode()) |parent| {
if (parent.is(Element)) |el| {
if (el.inlineStyle(frame)) |style| {
x += CSS.parseTranslateX(style.asCSSStyleDeclaration().getPropertyValue("transform", frame));
}
}
var sibling = parent.firstChild();
while (sibling) |s| : (sibling = s.nextSibling()) {
if (s == current) break;
if (s.is(Element)) |el| {
if (!style_manager.hasDisplayNone(el)) {
x += el.getElementAxis(frame, .width).value;
}
}
}
current = parent;
}
return x;
}
pub fn getElementsByTagName(self: *Element, tag_name: []const u8, frame: *Frame) !Node.GetElementsByTagNameResult {
return self.asNode().getElementsByTagName(tag_name, frame);
}
pub fn getElementsByTagNameNS(self: *Element, namespace: ?[]const u8, local_name: []const u8, frame: *Frame) !collections.NodeLive(.tag_name_ns) {
return self.asNode().getElementsByTagNameNS(namespace, local_name, frame);
}
pub fn getElementsByClassName(self: *Element, class_name: []const u8, frame: *Frame) !collections.NodeLive(.class_name) {
return self.asNode().getElementsByClassName(class_name, frame);
}
pub fn clone(self: *Element, deep: bool, document: *const Node.Document, frame: *Frame) !*Node {
const tag_name = self.getTagNameDump();
const node = try Frame.node_factory.createElementNS(document, self._namespace, tag_name, &self._attributes);
// A namespace outside the built-in set lives in a side table; the clone
// must report the same namespaceURI.
if (self._namespace == .unknown) {
const page = document._page;
if (page.element_namespace_uris.get(self)) |uri| {
try page.element_namespace_uris.put(page.frame_arena, node.as(Element), uri);
}
}
// Allow element-specific types to copy their runtime state
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), deep, frame }) catch |err| {
log.err(.dom, "element.clone.failed", .{ .err = err });
};
// Per spec, a clonable shadow root is cloned along with its host — its
// children always deep-cloned, even when the host clone is shallow.
if (self.hostedShadowRoot(frame)) |shadow| {
if (shadow._clonable) {
const cloned_shadow = node.as(Element).attachShadow(.{
.mode = shadow._mode,
.clonable = true,
.delegates_focus = shadow._delegates_focus,
.slot_assignment = shadow._slot_assignment,
.serializable = shadow._serializable,
.declarative = shadow._declarative,
}, frame) catch return error.CloneError;
try shadow.asNode().cloneChildrenInto(cloned_shadow.asNode(), document, frame);
}
}
if (deep) {
try self.asNode().cloneChildrenInto(node, document, frame);
}
return node;
}
pub fn scrollIntoViewIfNeeded(self: *Element, center_if_needed: ?bool, frame: *Frame) void {
_ = center_if_needed;
const y = calculateDocumentPosition(self.asNode());
const scroll_y: f64 = @floatFromInt(frame.window.getScrollY());
const viewport_height: f64 = @floatFromInt(frame.window.getInnerHeight(frame));
if (y >= scroll_y and y <= scroll_y + viewport_height) {
return;
}
self.scrollIntoView(null, frame);
}
const ScrollIntoViewOpts = union {
align_to_top: bool,
obj: js.Object,
};
pub fn scrollIntoView(self: *Element, opts: ?ScrollIntoViewOpts, frame: *Frame) void {
_ = opts;
// Scroll the window so the element's top is brought into the viewport.
// Positions come from the faux-layout document position (top = preorder
// depth-scaled y), the same source getBoundingClientRect uses.
const y = calculateDocumentPosition(self.asNode());
frame.window.scrollTo(.{ .x = 0 }, @trunc(@max(0, y)), frame) catch {};
}
// The scrollTo/scrollBy argument shape shared with Window: positional (x, y)
// or a dictionary.
pub const ScrollToOpts = union(enum) {
x: i32,
opts: Opts,
pub const Opts = struct {
behavior: []const u8 = "",
left: ?i32 = null,
top: ?i32 = null,
};
pub const Offsets = struct { left: ?i32, top: ?i32 };
/// Per-axis values; null leaves that axis where it is. Only the dictionary
/// form can omit an axis.
pub fn offsets(self: ScrollToOpts, y: ?i32) Offsets {
return switch (self) {
.x => |x| .{ .left = x, .top = y orelse 0 },
.opts => |o| .{ .left = o.left, .top = o.top },
};
}
};
pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = (opts orelse return).offsets(y);
_ = try self.writeScroll(.{ .to = o }, frame);
}
// scrollBy(): like scrollTo() but relative to the current position.
pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void {
const o = (opts orelse return).offsets(y);
_ = try self.writeScroll(.{ .by = o }, frame);
}
/// Reports whether the container moved: a wheel walks outward until one does.
pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !bool {
return self.writeScroll(.{ .by = switch (axis) {
.width => .{ .left = delta, .top = null },
.height => .{ .left = null, .top = delta },
} }, frame);
}
const ScrollWrite = union(enum) {
to: ScrollToOpts.Offsets,
by: ScrollToOpts.Offsets,
// Null leaves that axis alone.
fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 {
const offsets = switch (self) {
inline else => |o| o,
};
const value = switch (axis) {
.width => offsets.left,
.height => offsets.top,
} orelse return null;
return switch (self) {
.to => value,
.by => @as(i64, current) + value,
};
}
};
/// Every scroll write goes through here. Reports whether anything moved; one
/// that lands where the offsets already are doesn't even take a map entry.
fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !bool {
const owner = self.ownerFrame(frame) orelse return false;
const current: ScrollPosition = owner.page.element_scroll_positions.get(self) orelse .{};
var x = current.x;
var y = current.y;
if (write.target(.width, current.x)) |target| {
x = self.clampScroll(frame, .width, target);
}
if (write.target(.height, current.y)) |target| {
y = self.clampScroll(frame, .height, target);
}
if (x == current.x and y == current.y) {
return false;
}
const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = .{};
}
gop.value_ptr.x = x;
gop.value_ptr.y = y;
try self.scheduleScrollEvents(gop.value_ptr, owner);
return true;
}
fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 {
var clamped = target;
if (clamped < 0) {
clamped = 0;
} else if (self.scrollExtent(frame, axis)) |extent| {
const max: i64 = @floor(extent);
clamped = @min(clamped, max);
}
return @intCast(@min(clamped, std.math.maxInt(u32)));
}
// Scrolling an element fires a scroll event and then a scrollend event,
// asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the
// scrolling element (the root) are fired at the document instead.
// `frame` is the element's owner frame and `pos` its entry there, both
// resolved by writeScroll.
fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void {
const task_pending = pos.state != .done;
pos.state = .scroll;
if (task_pending) {
return;
}
const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self });
errdefer {
pos.state = .done;
frame._factory.destroy(task);
}
try frame.js.scheduler.add(task, ScrollEventTask.run, 10, .{
.name = "element.scrollEvents",
.blocks_done = false,
.finalizer = ScrollEventTask.cancelled,
});
}
const ScrollEventTask = struct {
frame: *Frame,
element: *Element,
fn eventTarget(self: *ScrollEventTask) *@import("EventTarget.zig") {
if (self.frame.document.getDocumentElement() == self.element) {
return self.frame.document.asEventTarget();
}
return self.element.asEventTarget();
}
// Scroll events fired at an element don't bubble; only document-level
// scrolls (the scrolling element, dispatched at the document) do.
fn bubbles(self: *ScrollEventTask) bool {
return self.frame.document.getDocumentElement() == self.element;
}
fn cancelled(ptr: *anyopaque) void {
const self: *ScrollEventTask = @ptrCast(@alignCast(ptr));
if (self.frame.page.element_scroll_positions.getPtr(self.element)) |pos| {
pos.state = .done;
}
self.frame._factory.destroy(self);
}
fn run(ptr: *anyopaque) anyerror!?u32 {
const self: *ScrollEventTask = @ptrCast(@alignCast(ptr));
const f = self.frame;
const pos = f.page.element_scroll_positions.getPtr(self.element) orelse {
f._factory.destroy(self);
return null;
};
switch (pos.state) {
.scroll => {
pos.state = .end;
self.dispatchEvent(comptime .wrap("scroll"));
return 10;
},
.end => {
pos.state = .done;
defer f._factory.destroy(self);
self.dispatchEvent(comptime .wrap("scrollend"));
return null;
},
.done => {
f._factory.destroy(self);
return null;
},
}
}
fn dispatchEvent(self: *ScrollEventTask, comptime event_type: String) void {
const Event = @import("Event.zig");
const event = Event.initTrusted(event_type, .{ .bubbles = self.bubbles() }, self.frame.page) catch |err| {
log.warn(.dom, "element.scroll.event", .{ .err = err });
return;
};
self.frame._event_manager.dispatch(self.eventTarget(), event) catch |err| {
log.warn(.dom, "element.scroll.dispatch", .{ .err = err });
};
}
};
pub fn format(self: *Element, writer: *std.Io.Writer) !void {
try writer.writeByte('<');
try writer.writeAll(self.getTagNameDump());
for (self._attributes.entries()) |*attr| {
try writer.print(" {f}", .{attr});
}
try writer.writeByte('>');
}
fn upperTagName(tag_name: *String, buf: []u8) []const u8 {
if (tag_name.len > buf.len) {
log.info(.dom, "tag.long.name", .{ .name = tag_name.str() });
return tag_name.str();
}
const tag = tag_name.str();
return std.ascii.upperString(buf, tag);
}
pub fn getTag(self: *const Element) Tag {
return switch (self._type) {
.html => blk: {
const he = self.subtype(Html);
break :blk switch (he._type) {
.anchor => .anchor,
.area => .area,
.base => .base,
.div => .div,
.dl => .dl,
.embed => .embed,
.form => .form,
.p => .p,
.custom => .custom,
.data => .data,
.datalist => .datalist,
.details => .details,
.dialog => .dialog,
.directory => .directory,
.iframe => .iframe,
.img => .img,
.br => .br,
.button => .button,
.canvas => .canvas,
.fieldset => .fieldset,
.font => .font,
.frameset => .frameset,
.heading => he.subtype(Html.Heading)._tag,
.label => .label,
.legend => .legend,
.li => .li,
.map => .map,
.marquee => .marquee,
.ul => .ul,
.ol => .ol,
.object => .object,
.optgroup => .optgroup,
.output => .output,
.picture => .picture,
.param => .param,
.pre => .pre,
.generic => he.subtype(Html.Generic)._tag,
.media => switch (he.subtype(Html.Media)._type) {
.audio => .audio,
.video => .video,
.generic => .media,
},
.meter => .meter,
.mod => he.subtype(Html.Mod)._tag,
.progress => .progress,
.quote => he.subtype(Html.Quote)._tag,
.script => .script,
.select => .select,
.slot => .slot,
.source => .source,
.span => .span,
.option => .option,
.table => .table,
.table_caption => .caption,
.table_cell => he.subtype(Html.TableCell)._tag,
.table_col => he.subtype(Html.TableCol)._tag,
.table_row => .tr,
.table_section => he.subtype(Html.TableSection)._tag,
.template => .template,
.textarea => .textarea,
.time => .time,
.track => .track,
.input => .input,
.link => .link,
.meta => .meta,
.hr => .hr,
.style => .style,
.title => .title,
.body => .body,
.html => .html,
.head => .head,
.unknown => .unknown,
};
},
.svg => self.subtype(Svg).getTag(),
};
}
pub fn ownerFrame(self: *const Element, frame: *const Frame) ?*Frame {
return self.asConstNode().ownerFrame(frame);
}
pub const Tag = enum {
address,
anchor,
audio,
area,
aside,
article,
b,
blockquote,
body,
br,
button,
base,
canvas,
caption,
circle,
code,
col,
colgroup,
custom,
data,
datalist,
dd,
details,
del,
dfn,
dialog,
div,
directory,
dl,
dt,
embed,
ellipse,
em,
fieldset,
figure,
frameset,
form,
font,
footer,
g,
h1,
h2,
h3,
h4,
h5,
h6,
head,
header,
heading,
hgroup,
hr,
html,
i,
iframe,
img,
input,
ins,
label,
legend,
li,
line,
link,
main,
map,
marquee,
media,
menu,
meta,
meter,
nav,
noscript,
object,
ol,
optgroup,
option,
output,
p,
path,
param,
picture,
polygon,
polyline,
pre,
progress,
quote,
rect,
s,
script,
section,
select,
slot,
source,
span,
strong,
style,
sub,
summary,
sup,
svg,
table,
time,
tbody,
td,
text,
template,
textarea,
tfoot,
th,
thead,
title,
tr,
track,
ul,
video,
unknown,
// If the tag is "unknown", we can't use the optimized tag matching, but
// need to fallback to the actual tag name
pub fn parseForMatch(lower: []const u8) ?Tag {
const tag = std.meta.stringToEnum(Tag, lower) orelse return null;
return switch (tag) {
.unknown, .custom => null,
else => tag,
};
}
pub fn isBlock(self: Tag) bool {
// zig fmt: off
return switch (self) {
// Semantic Layout
.article, .aside, .footer, .header, .main, .nav, .section,
// Grouping / Containers
.address, .div, .fieldset, .figure, .p,
// Headings
.h1, .h2, .h3, .h4, .h5, .h6,
// Lists
.dl, .ol, .ul,
// Preformatted / Quotes
.blockquote, .pre,
// Tables
.table,
// Other
.hr,
=> true,
else => false,
};
// zig fmt: on
}
pub fn isMetadata(self: Tag) bool {
return switch (self) {
.base, .head, .link, .meta, .noscript, .script, .style, .template, .title => true,
else => false,
};
}
// UA stylesheet display:none defaults per HTML Rendering §15.3.1
// "Hidden elements" (https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements).
// The spec also lists basefont, noembed, noframes, rp; those tags are
// obsolete and not represented in this enum, so they fall through to
// `.unknown`/`.custom` and aren't matched here.
pub fn isHiddenByUaStylesheet(self: Tag) bool {
return switch (self) {
.area,
.base,
.datalist,
.head,
.link,
.meta,
.noscript,
.param,
.script,
.source,
.style,
.template,
.title,
.track,
=> true,
else => false,
};
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(Element);
pub const Meta = struct {
pub const name = "Element";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const tagName = bridge.accessor(_tagName, null, .{});
fn _tagName(self: *Element, frame: *Frame) []const u8 {
return self.getTagNameSpec(&frame.buf);
}
// the frame-aware variant returns the original URI for namespaces
// outside the built-in set instead of the placeholder
pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});
pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{ .ce_reactions = true });
fn _innerText(self: *Element, frame: *Frame) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getInnerText(&buf.writer, frame);
return buf.written();
}
pub const outerHTML = bridge.accessor(_getOuterHTML, _setOuterHTML, .{ .ce_reactions = true });
fn _getOuterHTML(self: *Element, frame: *Frame) ![]const u8 {
// local_arena: serialization is read-only and the returned string is
// converted to v8 before this call returns. No JS runs in between.
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getOuterHTML(&buf.writer, frame);
return buf.written();
}
fn _setOuterHTML(self: *Element, value: js.Value, frame: *Frame) !void {
// `[LegacyNullToEmptyString] DOMString`: a JS null becomes "", not "null".
return self.setOuterHTML(if (value.isNull()) "" else try value.toZig([]const u8), frame);
}
pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true });
fn _getInnerHTML(self: *Element, frame: *Frame) ![]const u8 {
// local_arena: read-only serialization, result converted to v8 before
// returning; no JS runs in between.
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getInnerHTML(&buf.writer, frame);
return buf.written();
}
fn _setInnerHTML(self: *Element, value: js.Value, frame: *Frame) !void {
// `[LegacyNullToEmptyString] DOMString`: a JS null becomes "", not "null".
return self.setInnerHTML(if (value.isNull()) "" else try value.toZig([]const u8), frame);
}
pub const getHTML = bridge.function(_getHTML, .{});
const GetHTMLOpts = struct {
serializableShadowRoots: bool = false,
shadowRoots: []const *ShadowRoot = &.{},
};
fn _getHTML(self: *Element, opts_: ?GetHTMLOpts, frame: *Frame) ![]const u8 {
const opts = opts_ orelse GetHTMLOpts{};
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getHTML(.{
.shadow_roots = opts.shadowRoots,
.serializable_shadow_roots = opts.serializableShadowRoots,
}, &buf.writer, frame);
return buf.written();
}
pub const prefix = bridge.accessor(Element._prefix, null, .{});
pub const setAttribute = bridge.function(_setAttribute, .{ .ce_reactions = true });
fn _setAttribute(self: *Element, name: String, value: js.Value, frame: *Frame) !void {
return self.setAttribute(name, .wrap(try value.toStringSlice()), frame);
}
pub const setAttributeNS = bridge.function(_setAttributeNS, .{ .ce_reactions = true });
fn _setAttributeNS(self: *Element, maybe_ns: ?[]const u8, qn: []const u8, value: js.Value, frame: *Frame) !void {
return self.setAttributeNS(maybe_ns, qn, .wrap(try value.toStringSlice()), frame);
}
pub const localName = bridge.accessor(Element.getLocalName, null, .{});
pub const id = bridge.accessor(struct {
fn wrap(self: *const Element) []const u8 {
return self.getId() orelse "";
}
}.wrap, Element.setId, .{ .ce_reactions = true });
pub const slot = bridge.accessor(struct {
fn wrap(self: *const Element) []const u8 {
return self.getSlot() orelse "";
}
}.wrap, Element.setSlot, .{ .ce_reactions = true });
pub const role = ariaAccessor("role");
pub const ariaAtomic = ariaAccessor("aria-atomic");
pub const ariaAutoComplete = ariaAccessor("aria-autocomplete");
pub const ariaBrailleLabel = ariaAccessor("aria-braillelabel");
pub const ariaBrailleRoleDescription = ariaAccessor("aria-brailleroledescription");
pub const ariaBusy = ariaAccessor("aria-busy");
pub const ariaChecked = ariaAccessor("aria-checked");
pub const ariaColCount = ariaAccessor("aria-colcount");
pub const ariaColIndex = ariaAccessor("aria-colindex");
pub const ariaColIndexText = ariaAccessor("aria-colindextext");
pub const ariaColSpan = ariaAccessor("aria-colspan");
pub const ariaCurrent = ariaAccessor("aria-current");
pub const ariaDescription = ariaAccessor("aria-description");
pub const ariaDisabled = ariaAccessor("aria-disabled");
pub const ariaExpanded = ariaAccessor("aria-expanded");
pub const ariaHasPopup = ariaAccessor("aria-haspopup");
pub const ariaHidden = ariaAccessor("aria-hidden");
pub const ariaInvalid = ariaAccessor("aria-invalid");
pub const ariaKeyShortcuts = ariaAccessor("aria-keyshortcuts");
pub const ariaLabel = ariaAccessor("aria-label");
pub const ariaLevel = ariaAccessor("aria-level");
pub const ariaLive = ariaAccessor("aria-live");
pub const ariaModal = ariaAccessor("aria-modal");
pub const ariaMultiLine = ariaAccessor("aria-multiline");
pub const ariaMultiSelectable = ariaAccessor("aria-multiselectable");
pub const ariaOrientation = ariaAccessor("aria-orientation");
pub const ariaPlaceholder = ariaAccessor("aria-placeholder");
pub const ariaPosInSet = ariaAccessor("aria-posinset");
pub const ariaPressed = ariaAccessor("aria-pressed");
pub const ariaReadOnly = ariaAccessor("aria-readonly");
pub const ariaRelevant = ariaAccessor("aria-relevant");
pub const ariaRequired = ariaAccessor("aria-required");
pub const ariaRoleDescription = ariaAccessor("aria-roledescription");
pub const ariaRowCount = ariaAccessor("aria-rowcount");
pub const ariaRowIndex = ariaAccessor("aria-rowindex");
pub const ariaRowIndexText = ariaAccessor("aria-rowindextext");
pub const ariaRowSpan = ariaAccessor("aria-rowspan");
pub const ariaSelected = ariaAccessor("aria-selected");
pub const ariaSetSize = ariaAccessor("aria-setsize");
pub const ariaSort = ariaAccessor("aria-sort");
pub const ariaValueMax = ariaAccessor("aria-valuemax");
pub const ariaValueMin = ariaAccessor("aria-valuemin");
pub const ariaValueNow = ariaAccessor("aria-valuenow");
pub const ariaValueText = ariaAccessor("aria-valuetext");
pub const dir = bridge.accessor(struct {
fn wrap(self: *const Element) []const u8 {
return self.getDir() orelse "";
}
}.wrap, Element.setDir, .{ .ce_reactions = true });
pub const className = bridge.accessor(struct {
fn wrap(self: *const Element) []const u8 {
return self.getClassName() orelse "";
}
}.wrap, Element.setClassName, .{ .ce_reactions = true });
pub const classList = bridge.accessor(Element.getClassList, Element.setClassList, .{ .ce_reactions = true });
pub const part = bridge.accessor(Element.getPartList, null, .{});
pub const dataset = bridge.accessor(Element.getDataset, null, .{});
pub const style = bridge.accessor(Element.getOrCreateStyle, Element.setStyle, .{});
pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{});
pub const hasAttribute = bridge.function(Element.hasAttribute, .{});
pub const hasAttributeNS = bridge.function(Element.hasAttributeNS, .{});
pub const hasAttributes = bridge.function(Element.hasAttributes, .{});
pub const getAttribute = bridge.function(Element.getAttribute, .{});
pub const getAttributeNS = bridge.function(Element.getAttributeNS, .{});
pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{});
pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{ .ce_reactions = true });
pub const removeAttribute = bridge.function(Element.removeAttribute, .{ .ce_reactions = true });
pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .ce_reactions = true });
pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{});
pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .ce_reactions = true });
pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{});
pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{});
pub const attachShadow = bridge.function(_attachShadow, .{});
pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .ce_reactions = true });
pub const setHTMLUnsafe = bridge.function(Element.setHTMLUnsafe, .{ .ce_reactions = true });
pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .ce_reactions = true });
pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .ce_reactions = true });
const ShadowRootInit = struct {
clonable: bool = false,
delegatesFocus: bool = false,
mode: String,
serializable: bool = false,
slotAssignment: ?String = null,
};
fn _attachShadow(self: *Element, init: ShadowRootInit, frame: *Frame) !*ShadowRoot {
const mode: ShadowRoot.Mode = blk: {
if (init.mode.eql(comptime .wrap("open"))) break :blk .open;
if (init.mode.eql(comptime .wrap("closed"))) break :blk .closed;
return error.InvalidArgument;
};
const slot_assignment: ShadowRoot.SlotAssignment = blk: {
const sa = init.slotAssignment orelse break :blk .named;
if (sa.eql(comptime .wrap("named"))) break :blk .named;
if (sa.eql(comptime .wrap("manual"))) break :blk .manual;
return error.InvalidArgument;
};
return self.attachShadow(.{
.mode = mode,
.delegates_focus = init.delegatesFocus,
.slot_assignment = slot_assignment,
.clonable = init.clonable,
.serializable = init.serializable,
}, frame);
}
pub const replaceChildren = bridge.function(Element.replaceChildren, .{ .ce_reactions = true });
pub const replaceWith = bridge.function(Element.replaceWith, .{ .ce_reactions = true });
pub const remove = bridge.function(Element.remove, .{ .ce_reactions = true });
pub const append = bridge.function(Element.append, .{ .ce_reactions = true });
pub const prepend = bridge.function(Element.prepend, .{ .ce_reactions = true });
pub const moveBefore = bridge.function(Element.moveBefore, .{ .ce_reactions = true });
pub const before = bridge.function(Element.before, .{ .ce_reactions = true });
pub const after = bridge.function(Element.after, .{ .ce_reactions = true });
pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{});
pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{});
pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{});
pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{});
pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{});
pub const matches = bridge.function(Element.matches, .{});
pub const webkitMatchesSelector = bridge.function(Element.matches, .{});
pub const querySelector = bridge.function(Element.querySelector, .{});
pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{});
pub const closest = bridge.function(Element.closest, .{});
pub const getAnimations = bridge.function(Element.getAnimations, .{});
pub const animate = bridge.function(Element.animate, .{});
pub const checkVisibility = bridge.function(Element.checkVisibility, .{});
pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{});
pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{});
pub const clientTop = bridge.accessor(Element.getClientTop, null, .{});
pub const clientLeft = bridge.accessor(Element.getClientLeft, null, .{});
pub const scrollTop = bridge.accessor(Element.getScrollTop, Element.setScrollTop, .{});
pub const scrollLeft = bridge.accessor(Element.getScrollLeft, Element.setScrollLeft, .{});
pub const scrollHeight = bridge.accessor(Element.getScrollHeight, null, .{});
pub const scrollWidth = bridge.accessor(Element.getScrollWidth, null, .{});
pub const offsetTop = bridge.accessor(Element.getOffsetTop, null, .{});
pub const offsetLeft = bridge.accessor(Element.getOffsetLeft, null, .{});
pub const offsetWidth = bridge.accessor(Element.getOffsetWidth, null, .{});
pub const offsetHeight = bridge.accessor(Element.getOffsetHeight, null, .{});
pub const offsetParent = bridge.accessor(Element.getOffsetParent, null, .{});
pub const getClientRects = bridge.function(Element.getClientRects, .{});
pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{});
pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{});
pub const getElementsByTagNameNS = bridge.function(Element.getElementsByTagNameNS, .{});
pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{});
pub const children = bridge.accessor(Element.getChildren, null, .{});
pub const focus = bridge.function(Element.focus, .{});
pub const blur = bridge.function(Element.blur, .{});
pub const scrollIntoView = bridge.function(Element.scrollIntoView, .{});
pub const scrollIntoViewIfNeeded = bridge.function(Element.scrollIntoViewIfNeeded, .{});
pub const scroll = bridge.function(Element.scrollTo, .{});
pub const scrollTo = bridge.function(Element.scrollTo, .{});
pub const scrollBy = bridge.function(Element.scrollBy, .{});
fn ariaAccessor(comptime attr: []const u8) js.bridge.Accessor {
const R = struct {
pub fn get(self: *const Element) ?[]const u8 {
return self.getAttributeSafe(.wrap(attr));
}
pub fn set(self: *Element, value: ?[]const u8, frame: *Frame) !void {
if (value) |v| {
try self.setAttributeSafe(.wrap(attr), .wrap(v), frame);
} else {
try self.removeAttribute(.wrap(attr), frame);
}
}
};
return bridge.accessor(R.get, R.set, .{ .ce_reactions = true });
}
};
pub const Build = struct {
// Calls `func_name` with `args` on the most specific type where it is
// implement. This could be on the Element itself.
pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool {
switch (self._type) {
inline else => |tag| {
const S = Subtype(tag);
if (@hasDecl(S, "Build")) {
// The inner type has its own "call" method. Defer to it.
if (@hasDecl(S.Build, "call")) {
return S.Build.call(self.subtype(S), func_name, args);
}
// The inner type implements this function. Call it and we're done.
if (@hasDecl(S, func_name)) {
return @call(.auto, @field(S, func_name), args);
}
}
},
}
if (@hasDecl(Element.Build, func_name)) {
// Our last resort - the element implements this function.
try @call(.auto, @field(Element.Build, func_name), args);
return true;
}
// inform our caller (the Node) that we didn't find anything that implemented
// func_name and it should keep searching for a match.
return false;
}
};
const testing = @import("../../testing.zig");
test "WebApi: Element" {
try testing.htmlRunner("element", .{});
}
test "Element: scroll extent" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const root = try frame.window._document.createElement("div", null, frame);
try Frame.parse.htmlAsChildren(frame, root.asNode(),
\\<div style="height: 100px; overflow: auto"><div style="height: 500px"></div></div>
\\<div style="height: 100px; overflow: auto">text, and no element child to measure</div>
);
const box = root.asNode().firstChild().?.as(Element);
const text = box.nextElementSibling().?;
try box.setScrollTop(9999, frame);
try testing.expectEqual(400, box.getScrollTop(frame));
try box.setScrollTop(-1, frame);
try testing.expectEqual(0, box.getScrollTop(frame));
// A real browser clamps this one too, to the height of its text. contentAxis
// sums element children only, so we have no extent to clamp against and the
// offset stays unbounded.
try text.setScrollTop(9999, frame);
try testing.expectEqual(9999, text.getScrollTop(frame));
}
test "Element: div chain slot size" {
// Guard against accidental growth: new Element fields (e.g. _flags) must
// fit in existing padding. Debug is larger from the _proto_canary fields.
const Div = @import("element/html/Div.zig");
const slot = comptime Factory.chainOffsetOf(Div, Div) + @sizeOf(Div);
try testing.expectEqual(if (comptime lp.IS_DEBUG) 120 else 74, slot);
}