dom: implement HTMLDialogElement.{show, showModal, close}

The HTMLDialogElement constructor was exposed with `open` / `returnValue`
IDL accessors, but the three instance methods that drive the open/close
state were missing. Per HTML §4.11.4 (The dialog element), `show()` sets
the `open` attribute if absent; `showModal()` throws `InvalidStateError`
when the dialog is already open and otherwise sets `open`; `close()`
removes `open`, optionally updates `returnValue`, and fires a non-
bubbling `close` event. The non-rendering steps (focus trap, backdrop,
top-layer placement) are intentional no-ops here — `[open]` reflecting
through to selectors and the `close` event firing are what downstream
CDP clients rely on.

Closes #2434
This commit is contained in:
Navid EMAD
2026-05-12 19:02:10 +02:00
parent 4c58f8a6d0
commit 989e2d03a2
2 changed files with 158 additions and 0 deletions

View File

@@ -76,3 +76,125 @@
testing.expectTrue(dialog instanceof HTMLDialogElement)
}
</script>
<script id="show_sets_open">
{
const dialog = document.createElement('dialog')
testing.expectEqual(false, dialog.open)
dialog.show()
testing.expectEqual(true, dialog.open)
testing.expectEqual('', dialog.getAttribute('open'))
}
</script>
<script id="show_noop_when_already_open">
{
const dialog = document.createElement('dialog')
dialog.setAttribute('open', '')
testing.expectEqual(true, dialog.open)
dialog.show()
testing.expectEqual(true, dialog.open)
}
</script>
<script id="showModal_sets_open">
{
const dialog = document.createElement('dialog')
testing.expectEqual(false, dialog.open)
dialog.showModal()
testing.expectEqual(true, dialog.open)
testing.expectEqual('', dialog.getAttribute('open'))
}
</script>
<script id="showModal_throws_when_already_open">
{
const dialog = document.createElement('dialog')
dialog.setAttribute('open', '')
testing.expectError('InvalidStateError', () => dialog.showModal())
testing.expectEqual(true, dialog.open)
}
</script>
<script id="close_removes_open">
{
const dialog = document.createElement('dialog')
dialog.show()
testing.expectEqual(true, dialog.open)
dialog.close()
testing.expectEqual(false, dialog.open)
testing.expectEqual(null, dialog.getAttribute('open'))
}
</script>
<script id="close_noop_when_not_open">
{
const dialog = document.createElement('dialog')
testing.expectEqual(false, dialog.open)
dialog.close()
testing.expectEqual(false, dialog.open)
}
</script>
<script id="close_sets_returnValue_when_arg_given">
{
const dialog = document.createElement('dialog')
dialog.show()
dialog.close('confirmed')
testing.expectEqual('confirmed', dialog.returnValue)
testing.expectEqual('confirmed', dialog.getAttribute('returnvalue'))
}
</script>
<script id="close_preserves_returnValue_when_no_arg">
{
const dialog = document.createElement('dialog')
dialog.returnValue = 'preset'
dialog.show()
dialog.close()
testing.expectEqual('preset', dialog.returnValue)
}
</script>
<script id="close_fires_close_event">
{
const dialog = document.createElement('dialog')
document.body.appendChild(dialog)
dialog.show()
let fired = 0
let bubbled = false
let cancelable = null
dialog.addEventListener('close', (e) => {
fired++
cancelable = e.cancelable
})
document.addEventListener('close', () => { bubbled = true })
dialog.close('done')
testing.expectEqual(1, fired)
testing.expectEqual(false, bubbled)
testing.expectEqual(false, cancelable)
}
</script>
<script id="close_does_not_fire_event_when_not_open">
{
const dialog = document.createElement('dialog')
document.body.appendChild(dialog)
let fired = 0
dialog.addEventListener('close', () => fired++)
dialog.close()
testing.expectEqual(0, fired)
}
</script>

View File

@@ -1,6 +1,7 @@
const js = @import("../../../js/js.zig");
const Frame = @import("../../../Frame.zig");
const Event = @import("../../Event.zig");
const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
@@ -39,6 +40,37 @@ pub fn setReturnValue(self: *Dialog, value: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(comptime .wrap("returnvalue"), .wrap(value), frame);
}
/// https://html.spec.whatwg.org/multipage/interactive-elements.html#dom-dialog-show
/// If the open attribute is set, return; otherwise set it to the empty string.
/// Focus / inert / top-layer steps are no-ops here — no rendering pipeline.
pub fn show(self: *Dialog, frame: *Frame) !void {
if (self.getOpen()) return;
try self.asElement().setAttributeSafe(comptime .wrap("open"), .wrap(""), frame);
}
/// https://html.spec.whatwg.org/multipage/interactive-elements.html#dom-dialog-showmodal
/// Throws InvalidStateError if [open] is already set. Sets [open] otherwise.
/// Focus trap, backdrop, and top-layer placement are no-ops — Lightpanda has
/// no layout/compositor; [open] reflecting through to selectors is what
/// downstream consumers rely on.
pub fn showModal(self: *Dialog, frame: *Frame) !void {
if (self.getOpen()) return error.InvalidStateError;
try self.asElement().setAttributeSafe(comptime .wrap("open"), .wrap(""), frame);
}
/// https://html.spec.whatwg.org/multipage/interactive-elements.html#dom-dialog-close
/// If [open] is unset, return. Otherwise remove [open], optionally update
/// returnValue, and fire a `close` event (non-bubbling, non-cancelable).
pub fn close(self: *Dialog, return_value: ?[]const u8, frame: *Frame) !void {
if (!self.getOpen()) return;
try self.asElement().removeAttribute(comptime .wrap("open"), frame);
if (return_value) |v| {
try self.asElement().setAttributeSafe(comptime .wrap("returnvalue"), .wrap(v), frame);
}
const event = try Event.init("close", .{ .bubbles = false, .cancelable = false }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Dialog);
@@ -50,6 +82,10 @@ pub const JsApi = struct {
pub const open = bridge.accessor(Dialog.getOpen, Dialog.setOpen, .{});
pub const returnValue = bridge.accessor(Dialog.getReturnValue, Dialog.setReturnValue, .{});
pub const show = bridge.function(Dialog.show, .{});
pub const showModal = bridge.function(Dialog.showModal, .{ .dom_exception = true });
pub const close = bridge.function(Dialog.close, .{});
};
const testing = @import("../../../../testing.zig");