diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index d4d20394..5d4670d9 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -28,6 +28,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
+const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator;
@@ -254,20 +255,27 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
+ const page = self.page;
+ const activation_state = ActivationState.create(event, target, page);
+
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
defer {
event._event_phase = .none;
+ // Handle checkbox/radio activation rollback or commit
+ if (activation_state) |state| {
+ state.restore(event, page);
+ }
// Execute default action if not prevented
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
- } else if (event._type_string.eqlSlice("click")) {
- self.page.handleClick(target) catch |err| {
+ } else if (event._type_string.eql(comptime .wrap("click"))) {
+ page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err });
};
- } else if (event._type_string.eqlSlice("keydown")) {
- self.page.handleKeydown(target, event) catch |err| {
+ } else if (event._type_string.eql(comptime .wrap("keydown"))) {
+ page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = err });
};
}
@@ -302,7 +310,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
// Even though the window isn't part of the DOM, events always propagate
// through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) {
- path_buffer[path_len] = self.page.window.asEventTarget();
+ path_buffer[path_len] = page.window.asEventTarget();
path_len += 1;
}
@@ -488,7 +496,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
- const Element = @import("webapi/Element.zig");
const element = switch (target._type) {
.node => |n| n.is(Element) orelse return null,
else => return null,
@@ -612,3 +619,144 @@ fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
return false;
}
+
+// Handles the default action for clicking on input checked/radio. Maybe this
+// could be generalized if needed, but I'm not sure. This wasn't obvious to me
+// but when an input is clicked, it's important to think about both the intent
+// and the actual result. Imagine you have an unchecked checkbox. When clicked,
+// the checkbox immediately becomes checked, and event handlers see this "checked"
+// intent. But a listener can preventDefault() in which case the check we did at
+// the start will be undone.
+// This is a bit more complicated for radio buttons, as the checking/unchecking
+// and the rollback can impact a different radio input. So if you "check" a radio
+// the intent is that it becomes checked and whatever was checked before becomes
+// unchecked, so that if you have to rollback (because of a preventDefault())
+// then both inputs have to revert to their original values.
+const ActivationState = struct {
+ old_checked: bool,
+ input: *Element.Html.Input,
+ previously_checked_radio: ?*Input,
+
+ const Input = Element.Html.Input;
+
+ fn create(event: *const Event, target: *Node, page: *Page) ? ActivationState {
+ if (event._type_string.eql(comptime .wrap("click")) == false) {
+ return null;
+ }
+
+ const input = target.is(Element.Html.Input) orelse return null;
+ if (input._input_type != .checkbox and input._input_type != .radio) {
+ return null;
+ }
+
+ const old_checked = input._checked;
+ var previously_checked_radio: ?*Element.Html.Input = null;
+
+ // For radio buttons, find the currently checked radio in the group
+ if (input._input_type == .radio and !old_checked) {
+ previously_checked_radio = try findCheckedRadioInGroup(input, page);
+ }
+
+ // Toggle checkbox or check radio (which unchecks others in group)
+ const new_checked = if (input._input_type == .checkbox) !old_checked else true;
+ try input.setChecked(new_checked, page);
+
+ return .{
+ .input = input,
+ .old_checked = old_checked,
+ .previously_checked_radio = previously_checked_radio,
+ };
+ }
+
+ fn restore(self: *const ActivationState, event: *const Event, page: *Page) void {
+ const input = self.input;
+ if (event._prevent_default) {
+ // Rollback: restore previous state
+ input._checked = self.old_checked;
+ input._checked_dirty = true;
+ if (self.previously_checked_radio) |prev_radio| {
+ prev_radio._checked = true;
+ prev_radio._checked_dirty = true;
+ }
+ return;
+ }
+
+ // Commit: fire input and change events only if state actually changed
+ // For checkboxes, state always changes. For radios, only if was unchecked.
+ const state_changed = (input._input_type == .checkbox) or !self.old_checked;
+ if (state_changed) {
+ fireEvent(page, input, "input") catch |err| {
+ log.warn(.event, "input event", .{ .err = err });
+ };
+ fireEvent(page, input, "change") catch |err| {
+ log.warn(.event, "change event", .{ .err = err });
+ };
+ }
+ }
+
+ fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input {
+ const elem = input.asElement();
+
+ const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null;
+ if (name.len == 0) {
+ return null;
+ }
+
+ const form = input.getForm(page);
+
+ // Walk from the root of the tree containing this element
+ // This handles both document-attached and orphaned elements
+ const root = elem.asNode().getRootNode(null);
+
+ const TreeWalker = @import("webapi/TreeWalker.zig");
+ var walker = TreeWalker.Full.init(root, .{});
+
+ while (walker.next()) |node| {
+ const other_element = node.is(Element) orelse continue;
+ const other_input = other_element.is(Input) orelse continue;
+
+ if (other_input._input_type != .radio) {
+ continue;
+ }
+
+ // Skip the input we're checking from
+ if (other_input == input) {
+ continue;
+ }
+
+ const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
+ if (!std.mem.eql(u8, name, other_name)) {
+ continue;
+ }
+
+ // Check if same form context
+ const other_form = other_input.getForm(page);
+ if (form) |f| {
+ const of = other_form orelse continue;
+ if (f != of) {
+ continue; // Different forms
+ }
+ } else if (other_form != null) {
+ continue; // form is null but other has a form
+ }
+
+ if (other_input._checked) {
+ return other_input;
+ }
+ }
+
+ return null;
+ }
+
+ // Fire input or change event
+ fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void {
+ const event = try Event.initTrusted(comptime .wrap(typ), .{
+ .bubbles = true,
+ .cancelable = false,
+ }, page);
+ defer if (!event._v8_handoff) event.deinit(false);
+
+ const target = input.asElement().asEventTarget();
+ try page._event_manager.dispatch(target, event);
+ }
+};
diff --git a/src/browser/tests/element/html/input_click.html b/src/browser/tests/element/html/input_click.html
new file mode 100644
index 00000000..de0efff9
--- /dev/null
+++ b/src/browser/tests/element/html/input_click.html
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig
index 28e55f0a..7ab4db6b 100644
--- a/src/browser/webapi/element/html/Input.zig
+++ b/src/browser/webapi/element/html/Input.zig
@@ -496,13 +496,22 @@ fn uncheckRadioGroup(self: *Input, page: *Page) !void {
const my_form = self.getForm(page);
+ // Walk from the root of the tree containing this element
+ // This handles both document-attached and orphaned elements
+ const root = element.asNode().getRootNode(null);
+
const TreeWalker = @import("../../TreeWalker.zig");
- var walker = TreeWalker.Full.init(page.document.asNode(), .{});
+ var walker = TreeWalker.Full.init(root, .{});
while (walker.next()) |node| {
const other_element = node.is(Element) orelse continue;
const other_input = other_element.is(Input) orelse continue;
+ // Skip self
+ if (other_input == self) {
+ continue;
+ }
+
if (other_input._input_type != .radio) {
continue;
}
@@ -651,5 +660,6 @@ pub const Build = struct {
const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Input" {
try testing.htmlRunner("element/html/input.html", .{});
+ try testing.htmlRunner("element/html/input_click.html", .{});
try testing.htmlRunner("element/html/input_radio.html", .{});
}
diff --git a/src/http/Client.zig b/src/http/Client.zig
index cda2adeb..6e317d10 100644
--- a/src/http/Client.zig
+++ b/src/http/Client.zig
@@ -1255,7 +1255,6 @@ pub const Transfer = struct {
client.endTransfer(self);
}
self.deinit();
-
}
pub fn terminate(self: *Transfer) void {