diff --git a/src/browser/tests/element/html/contenteditable.html b/src/browser/tests/element/html/contenteditable.html
new file mode 100644
index 00000000..eca1ec2b
--- /dev/null
+++ b/src/browser/tests/element/html/contenteditable.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
own true
+own empty
+own false
+own plaintext-only
+
+
+ child
+
+
+
+
+no editing context
+
+
+
+
diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig
index 5853fd14..3ed96f57 100644
--- a/src/browser/webapi/element/Html.zig
+++ b/src/browser/webapi/element/Html.zig
@@ -382,6 +382,31 @@ pub fn setTitle(self: *HtmlElement, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(comptime .wrap("title"), .wrap(value), frame);
}
+// HTML §7.7.5.2 specifies the IDL attribute as true iff the element's effective
+// content editable state is "true" or "plaintext-only". Lightpanda has no
+// caret/keyboard editing pipeline, so a true answer cannot be honored
+// end-to-end — downstream CDP tools (notably Puppeteer's dispatchKeyEvent
+// path) would route into an input pipeline that silently no-ops. Always
+// return false, and log .not_implemented when the spec would have said true
+// so usage surfaces in telemetry rather than silently depending on an
+// unsupported value. Spec walk per HTML §7.7.5.2 still applies — the nearest
+// ancestor with `contenteditable` wins; "false" disables. See PR #2310 for
+// the routing-vs-fail-loud discussion.
+//
+// "contenteditable" is 15 bytes — past the comptime SSO limit — so the
+// String wrap runs at runtime, mirroring the pattern in interactive.zig.
+pub fn getIsContentEditable(self: *HtmlElement) bool {
+ var current: ?*Element = self.asElement();
+ while (current) |el| : (current = el.parentElement()) {
+ const raw = el.getAttributeSafe(.wrap("contenteditable")) orelse continue;
+ if (!std.ascii.eqlIgnoreCase(raw, "false")) {
+ log.info(.not_implemented, "IsContentEditable", .{});
+ }
+ break;
+ }
+ return false;
+}
+
pub fn getAttributeFunction(
self: *HtmlElement,
listener_type: GlobalEventHandler,
@@ -1220,6 +1245,7 @@ pub const JsApi = struct {
pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{});
pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{});
+ pub const isContentEditable = bridge.accessor(HtmlElement.getIsContentEditable, null, .{});
pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{});
pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{});
pub const title = bridge.accessor(HtmlElement.getTitle, HtmlElement.setTitle, .{});
@@ -1357,3 +1383,6 @@ test "WebApi: HTML.event_listeners" {
test "WebApi: HTMLElement.props" {
try testing.htmlRunner("element/html/htmlelement-props.html", .{});
}
+test "WebApi: HTMLElement.contenteditable" {
+ try testing.htmlRunner("element/html/contenteditable.html", .{});
+}