diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index d537c2df6..0fea2ba7c 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -147,6 +147,8 @@ _queued_events_1: std.ArrayList(QueuedEvent) = .empty,
_queued_events_2: std.ArrayList(QueuedEvent) = .empty,
_queued_events: *std.ArrayList(QueuedEvent) = undefined,
+_focus_fixup_pending: bool = false,
+
_style_manager: StyleManager,
_script_manager: ScriptManager,
@@ -2369,6 +2371,25 @@ pub fn queueElementEvent(self: *Frame, element: *Element.Html, kind: QueuedEvent
}
}
+// An element that becomes inert can't stay focused. Fire its blur on the next tick
+fn scheduleFocusFixup(self: *Frame) !void {
+ if (self._focus_fixup_pending or self.document._active_element == null) {
+ return;
+ }
+ try self.js.scheduler.add(self, struct {
+ fn run(ctx: *anyopaque) !?u32 {
+ const f: *Frame = @ptrCast(@alignCast(ctx));
+ f._focus_fixup_pending = false;
+ const active = f.document._active_element orelse return null;
+ if (active.asNode().isInert(f)) {
+ try active.blur(f);
+ }
+ return null;
+ }
+ }.run, 5, .{ .name = "frame.focusFixup" });
+ self._focus_fixup_pending = true;
+}
+
const HashChangeCallback = struct {
frame: *Frame,
old_url: []const u8,
@@ -3148,6 +3169,10 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str
} else if (name.eql(comptime .wrap("style"))) {
element._flags.has_inline_style = true;
self.styleAttributeChanged(element, value.str());
+ } else if (name.eql(comptime .wrap("inert"))) {
+ self.scheduleFocusFixup() catch |err| {
+ log.err(.frame, "scheduleFocusFixup", .{ .err = err, .type = self._type, .url = self.url });
+ };
}
}
diff --git a/src/browser/tests/element/inert.html b/src/browser/tests/element/inert.html
new file mode 100644
index 000000000..408b887cd
--- /dev/null
+++ b/src/browser/tests/element/inert.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+ hidden
+
+
+
shown
+
hidden
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index fded393c2..4283c67ba 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -1233,12 +1233,17 @@ pub fn focusTabIndex(self: *Element) ?i32 {
return null;
}
-// A focusable area that can take focus right now: connected and being rendered.
+// 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;
}
- if (self.asNode().isConnected() == false) {
+ const node = self.asNode();
+ if (node.isConnected() == false) {
+ return false;
+ }
+ if (node.isInert(frame)) {
return false;
}
return self.isVisible(frame);
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index d75d7bfbc..364178b6d 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -1699,6 +1699,27 @@ pub fn assignedSlot(self: *Node, frame: *const Frame) ?*Element.Html.Slot {
return frame.page._assigned_slots.get(self);
}
+// An inert element applies to all its chidren, so walk up to see if we have
+// an inert parent
+pub fn isInert(self: *Node, frame: *const Frame) bool {
+ var current: ?*Node = self;
+ while (current) |node| {
+ if (node.is(Element)) |el| {
+ if (el._namespace == .html and el.hasAttributeSafe(comptime .wrap("inert"))) {
+ return true;
+ }
+ }
+ if (node.assignedSlot(frame)) |slot| {
+ current = slot.asNode();
+ } else if (node.is(ShadowRoot)) |shadow| {
+ current = shadow._host.asNode();
+ } else {
+ current = node._parent;
+ }
+ }
+ return false;
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(Node);
diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig
index 349837467..22b1b316f 100644
--- a/src/browser/webapi/Range.zig
+++ b/src/browser/webapi/Range.zig
@@ -719,11 +719,19 @@ fn createContextualFragment(self: *const Range, html: []const u8, frame: *Frame)
pub fn toString(self: *const Range, frame: *Frame) ![]const u8 {
// Simplified implementation: just extract text content
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
- try self.writeTextContent(&buf.writer);
+ try self.writeTextContent(&buf.writer, null);
return buf.written();
}
-fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
+// Selection.toString is almost like Range.toString, except it does not include
+// the text that an inert element hides.
+pub fn toSelectionString(self: *const Range, frame: *Frame) ![]const u8 {
+ var buf = std.Io.Writer.Allocating.init(frame.local_arena);
+ try self.writeTextContent(&buf.writer, frame);
+ return buf.written();
+}
+
+fn writeTextContent(self: *const Range, writer: *std.Io.Writer, skip_inert: ?*const Frame) !void {
if (self._proto.getCollapsed()) return;
const start_node = self._proto._start_container;
@@ -734,7 +742,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
// Same text node — just substring
if (start_node == end_node) {
if (start_node.is(Node.CData)) |cdata| {
- if (!isCommentOrPI(cdata)) {
+ if (includeText(cdata, skip_inert)) {
const data = cdata.getData().str();
const s = byteOffset(data, start_offset);
const e = byteOffset(data, end_offset);
@@ -748,7 +756,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
// Partial start: if start container is a text node, write from offset to end
if (start_node.is(Node.CData)) |cdata| {
- if (!isCommentOrPI(cdata)) {
+ if (includeText(cdata, skip_inert)) {
const data = cdata.getData().str();
const s = byteOffset(data, start_offset);
try writer.writeAll(data[s..]);
@@ -775,7 +783,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
if (n == we) break;
}
if (n.is(Node.CData)) |cdata| {
- if (!isCommentOrPI(cdata)) {
+ if (includeText(cdata, skip_inert)) {
try writer.writeAll(cdata.getData().str());
}
}
@@ -786,7 +794,7 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
// Partial end: if end container is a different text node, write from start to offset
if (start_node != end_node) {
if (end_node.is(Node.CData)) |cdata| {
- if (!isCommentOrPI(cdata)) {
+ if (includeText(cdata, skip_inert)) {
const data = cdata.getData().str();
const e = byteOffset(data, end_offset);
try writer.writeAll(data[0..e]);
@@ -795,6 +803,14 @@ fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
}
}
+fn includeText(cdata: *Node.CData, skip_inert: ?*const Frame) bool {
+ if (isCommentOrPI(cdata)) {
+ return false;
+ }
+ const frame = skip_inert orelse return true;
+ return cdata.asNode().isInert(frame) == false;
+}
+
fn isCommentOrPI(cdata: *Node.CData) bool {
return cdata.is(Node.CData.Comment) != null or cdata.is(Node.CData.ProcessingInstruction) != null;
}
diff --git a/src/browser/webapi/Selection.zig b/src/browser/webapi/Selection.zig
index 41cb692bf..7124f1655 100644
--- a/src/browser/webapi/Selection.zig
+++ b/src/browser/webapi/Selection.zig
@@ -714,7 +714,7 @@ pub fn collapse(self: *Selection, _node: ?*Node, _offset: ?u32, frame: *Frame) !
pub fn toString(self: *const Selection, frame: *Frame) ![]const u8 {
const range = self._range orelse return "";
- return try range.toString(frame);
+ return try range.toSelectionString(frame);
}
fn setRange(self: *Selection, new_range: ?*Range, frame: *Frame) void {
diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig
index 51637d7e2..a671386f0 100644
--- a/src/browser/webapi/element/Html.zig
+++ b/src/browser/webapi/element/Html.zig
@@ -452,6 +452,18 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void {
}
}
+pub fn getInert(self: *HtmlElement) bool {
+ return self.asElement().hasAttributeSafe(comptime .wrap("inert"));
+}
+
+pub fn setInert(self: *HtmlElement, inert: bool, frame: *Frame) !void {
+ if (inert) {
+ try self.asElement().setAttributeSafe(comptime .wrap("inert"), .wrap(""), frame);
+ } else {
+ try self.asElement().removeAttribute(comptime .wrap("inert"), frame);
+ }
+}
+
// The translate IDL attribute reflects the element's translation mode:
// translate="yes"/"" enables it, "no" disables it, anything else (or no
// attribute) inherits from the parent, defaulting to enabled.
@@ -1862,6 +1874,7 @@ pub const JsApi = struct {
pub const dir = reflect.enumerated("dir", &.{ "ltr", "rtl", "auto" }, .{});
pub const draggable = bridge.accessor(HtmlElement.getDraggable, HtmlElement.setDraggable, .{ .ce_reactions = true });
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true });
+ pub const inert = bridge.accessor(HtmlElement.getInert, HtmlElement.setInert, .{ .ce_reactions = true });
pub const translate = bridge.accessor(HtmlElement.getTranslate, HtmlElement.setTranslate, .{ .ce_reactions = true });
pub const accessKeyLabel = bridge.accessor(HtmlElement.getAccessKeyLabel, null, .{});
pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true });