From 6037521c497a8bc08c247fd6e3b15bd0fa4e0a5e Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 13 Feb 2026 11:06:46 +0800 Subject: [PATCH] Default behavior for input click (radio / checkbox). This wasn't 100% intuitive to me. At the start of the event, the input is immediately toggled. But at any point during dispatching, the default behavior can be suppressed. So the state of the input's check during dispatching captures the "intent" of the click. But it's possible for one listener to see that input.checked == true even though, by the end of dispatching, input.checked == false because some other listener called preventDefault(). To support this, we need to capture the "current" state so that, if we need to rollback, we can. For radio buttons, this "current" state includes capturing the currently checked radio (if any). --- src/browser/EventManager.zig | 160 +++++++++- .../tests/element/html/input_click.html | 283 ++++++++++++++++++ src/browser/webapi/element/html/Input.zig | 12 +- src/http/Client.zig | 1 - 4 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 src/browser/tests/element/html/input_click.html 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 {