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 + math +
+ + + + + + + + + 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 });