webapi: add support for the inert property

Improves the /inert/ WPT category
This commit is contained in:
Karl Seguin committed 2026-09-23 18:21:31 +08:00
1 parent 414167bfcd
commit d5ba648b7e
7 files changed
+191 -9

No files matched your search

+25
View File
@@ -146,6 +146,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,
@@ -2356,6 +2358,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,
@@ -3135,6 +3156,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 });
};
}
}
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body>
<button id="outside">outside</button>
<div id="container" inert>
<input id="inner" type="text">
<span id="text">hidden</span>
</div>
<div id="selectable">
<p>shown</p>
<span inert>hidden</span>
<math inert><mi>math</mi></math>
</div>
</body>
<script id="inert_property">
{
testing.expectEqual(false, $('#outside').inert);
testing.expectEqual(true, $('#container').inert);
// inherited inertness isn't reflected
testing.expectEqual(false, $('#inner').inert);
const outside = $('#outside');
outside.inert = true;
testing.expectEqual('', outside.getAttribute('inert'));
outside.inert = false;
testing.expectEqual(false, outside.hasAttribute('inert'));
}
</script>
<script id="inert_unfocusable">
{
const outside = $('#outside');
outside.focus();
testing.expectEqual(outside, document.activeElement);
$('#inner').focus();
testing.expectEqual(outside, document.activeElement);
$('#container').inert = false;
$('#inner').focus();
testing.expectEqual($('#inner'), document.activeElement);
$('#container').inert = true;
}
</script>
<script id="inert_selection">
{
const selection = getSelection();
selection.selectAllChildren($('#selectable'));
// inert only applies to HTML elements, so the MathML text stays
testing.expectEqual('shown math', selection.toString().trim().replace(/\s+/g, ' '));
selection.removeAllRanges();
}
</script>
<script id="inert_focus_fixup" type=module>
const state = await testing.async();
const nextRendering = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const outside = $('#outside');
outside.focus();
const events = [];
outside.addEventListener('blur', () => events.push('blur'));
outside.addEventListener('focusout', () => events.push('focusout'));
outside.inert = true;
// like browsers, the fixup waits for the next rendering update
const syncActive = document.activeElement;
await nextRendering();
const fixedActive = document.activeElement;
outside.inert = false;
const host = document.createElement('div');
host.innerHTML = '<button id="slotted" slot="s">slotted</button>';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const slot = document.createElement('slot');
slot.name = 's';
slot.inert = true;
shadow.appendChild(slot);
const shadowButton = document.createElement('button');
shadow.appendChild(shadowButton);
$('#slotted').focus();
const slottedActive = document.activeElement;
shadowButton.focus();
const shadowActive = document.activeElement;
host.inert = true;
await nextRendering();
const hostFixedActive = document.activeElement;
host.remove();
state.resolve();
await state.done(() => {
testing.expectEqual(outside, syncActive);
testing.expectEqual(document.body, fixedActive);
testing.expectEqual(['blur', 'focusout'], events);
testing.expectEqual(document.body, slottedActive);
testing.expectEqual(host, shadowActive);
testing.expectEqual(document.body, hostFixedActive);
});
</script>
+7 -2
View File
@@ -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);
+21
View File
@@ -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);
+22 -6
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+13
View File
@@ -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 });