Merge pull request #1537 from lightpanda-io/input_click

Default behavior for input click (radio / checkbox).
This commit is contained in:
Karl Seguin
2026-02-14 07:02:05 +08:00
committed by GitHub
4 changed files with 448 additions and 8 deletions

View File

@@ -28,6 +28,7 @@ const Page = @import("Page.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig"); const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator; 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 { fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig"); 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 // Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented) // and default actions execute (unless prevented)
defer { defer {
event._event_phase = .none; 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 // Execute default action if not prevented
if (event._prevent_default) { if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻ // can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eqlSlice("click")) { } else if (event._type_string.eql(comptime .wrap("click"))) {
self.page.handleClick(target) catch |err| { page.handleClick(target) catch |err| {
log.warn(.event, "page.click", .{ .err = err }); log.warn(.event, "page.click", .{ .err = err });
}; };
} else if (event._type_string.eqlSlice("keydown")) { } else if (event._type_string.eql(comptime .wrap("keydown"))) {
self.page.handleKeydown(target, event) catch |err| { page.handleKeydown(target, event) catch |err| {
log.warn(.event, "page.keydown", .{ .err = 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 // 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) // through it in the capture phase (unless we stopped at a shadow boundary)
if (path_len < path_buffer.len) { if (path_len < path_buffer.len) {
path_buffer[path_len] = self.page.window.asEventTarget(); path_buffer[path_len] = page.window.asEventTarget();
path_len += 1; 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; const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target // Look up the inline handler for this target
const Element = @import("webapi/Element.zig");
const element = switch (target._type) { const element = switch (target._type) {
.node => |n| n.is(Element) orelse return null, .node => |n| n.is(Element) orelse return null,
else => return null, else => return null,
@@ -612,3 +619,144 @@ fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
return false; 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);
}
};

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- Checkbox click tests -->
<input id="checkbox1" type="checkbox">
<input id="checkbox2" type="checkbox" checked>
<input id="checkbox_disabled" type="checkbox" disabled>
<!-- Radio click tests -->
<input id="radio1" type="radio" name="clickgroup" checked>
<input id="radio2" type="radio" name="clickgroup">
<input id="radio3" type="radio" name="clickgroup">
<input id="radio_disabled" type="radio" name="clickgroup" disabled>
<script id="checkbox_click_toggles">
{
const cb = $('#checkbox1');
testing.expectEqual(false, cb.checked);
cb.click();
testing.expectEqual(true, cb.checked);
cb.click();
testing.expectEqual(false, cb.checked);
}
</script>
<script id="checkbox_click_preventDefault_reverts">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
testing.expectEqual(false, cb.checked);
cb.addEventListener('click', (e) => {
testing.expectEqual(true, cb.checked, 'checkbox should be checked during click handler');
e.preventDefault();
});
cb.click();
testing.expectEqual(false, cb.checked, 'checkbox should revert after preventDefault');
}
</script>
<script id="checkbox_click_events_order">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_preventDefault_no_input_change">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
const events = [];
cb.addEventListener('click', (e) => {
events.push('click');
e.preventDefault();
});
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(1, events.length, 'only click event should fire');
testing.expectEqual('click', events[0]);
document.body.removeChild(cb);
}
</script>
<script id="checkbox_click_state_visible_in_handler">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = true;
cb.addEventListener('click', (e) => {
testing.expectEqual(false, cb.checked, 'should see toggled state in handler');
e.preventDefault();
testing.expectEqual(false, cb.checked, 'should still be toggled after preventDefault in handler');
});
cb.click();
testing.expectEqual(true, cb.checked, 'should revert to original state after handler completes');
}
</script>
<script id="radio_click_checks_clicked">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
}
</script>
<script id="radio_click_preventDefault_reverts">
{
const r1 = document.createElement('input');
r1.type = 'radio';
r1.name = 'testgroup';
r1.checked = true;
const r2 = document.createElement('input');
r2.type = 'radio';
r2.name = 'testgroup';
document.body.appendChild(r1);
document.body.appendChild(r2);
r2.addEventListener('click', (e) => {
testing.expectEqual(false, r1.checked, 'r1 should be unchecked during click handler');
testing.expectEqual(true, r2.checked, 'r2 should be checked during click handler');
e.preventDefault();
});
r2.click();
testing.expectEqual(true, r1.checked, 'r1 should be restored after preventDefault');
testing.expectEqual(false, r2.checked, 'r2 should revert after preventDefault');
document.body.removeChild(r1);
document.body.removeChild(r2);
}
</script>
<script id="radio_click_events_order">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'eventtest';
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(3, events.length);
testing.expectEqual('click', events[0]);
testing.expectEqual('input', events[1]);
testing.expectEqual('change', events[2]);
document.body.removeChild(r);
}
</script>
<script id="radio_click_already_checked_no_events">
{
const r = document.createElement('input');
r.type = 'radio';
r.name = 'alreadytest';
r.checked = true;
document.body.appendChild(r);
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(1, events.length, 'only click event should fire for already-checked radio');
testing.expectEqual('click', events[0]);
document.body.removeChild(r);
}
</script>
<script id="disabled_checkbox_no_click">
{
const cb = $('#checkbox_disabled');
const events = [];
cb.addEventListener('click', () => events.push('click'));
cb.addEventListener('input', () => events.push('input'));
cb.addEventListener('change', () => events.push('change'));
cb.click();
testing.expectEqual(0, events.length, 'disabled checkbox should not fire any events');
}
</script>
<script id="disabled_radio_no_click">
{
const r = $('#radio_disabled');
const events = [];
r.addEventListener('click', () => events.push('click'));
r.addEventListener('input', () => events.push('input'));
r.addEventListener('change', () => events.push('change'));
r.click();
testing.expectEqual(0, events.length, 'disabled radio should not fire any events');
}
</script>
<script id="input_and_change_are_trusted">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
document.body.appendChild(cb);
let inputEvent = null;
let changeEvent = null;
cb.addEventListener('input', (e) => inputEvent = e);
cb.addEventListener('change', (e) => changeEvent = e);
cb.click();
testing.expectEqual(true, inputEvent.isTrusted, 'input event should be trusted');
testing.expectEqual(true, inputEvent.bubbles, 'input event should bubble');
testing.expectEqual(false, inputEvent.cancelable, 'input event should not be cancelable');
testing.expectEqual(true, changeEvent.isTrusted, 'change event should be trusted');
testing.expectEqual(true, changeEvent.bubbles, 'change event should bubble');
testing.expectEqual(false, changeEvent.cancelable, 'change event should not be cancelable');
document.body.removeChild(cb);
}
</script>
<script id="multiple_radios_click_sequence">
{
const r1 = $('#radio1');
const r2 = $('#radio2');
const r3 = $('#radio3');
// Reset to known state
r1.checked = true;
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
r2.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(true, r2.checked);
testing.expectEqual(false, r3.checked);
r3.click();
testing.expectEqual(false, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(true, r3.checked);
r1.click();
testing.expectEqual(true, r1.checked);
testing.expectEqual(false, r2.checked);
testing.expectEqual(false, r3.checked);
}
</script>

View File

@@ -496,13 +496,22 @@ fn uncheckRadioGroup(self: *Input, page: *Page) !void {
const my_form = self.getForm(page); 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"); const TreeWalker = @import("../../TreeWalker.zig");
var walker = TreeWalker.Full.init(page.document.asNode(), .{}); var walker = TreeWalker.Full.init(root, .{});
while (walker.next()) |node| { while (walker.next()) |node| {
const other_element = node.is(Element) orelse continue; const other_element = node.is(Element) orelse continue;
const other_input = other_element.is(Input) orelse continue; const other_input = other_element.is(Input) orelse continue;
// Skip self
if (other_input == self) {
continue;
}
if (other_input._input_type != .radio) { if (other_input._input_type != .radio) {
continue; continue;
} }
@@ -651,5 +660,6 @@ pub const Build = struct {
const testing = @import("../../../../testing.zig"); const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Input" { test "WebApi: HTML.Input" {
try testing.htmlRunner("element/html/input.html", .{}); try testing.htmlRunner("element/html/input.html", .{});
try testing.htmlRunner("element/html/input_click.html", .{});
try testing.htmlRunner("element/html/input_radio.html", .{}); try testing.htmlRunner("element/html/input_radio.html", .{});
} }

View File

@@ -1255,7 +1255,6 @@ pub const Transfer = struct {
client.endTransfer(self); client.endTransfer(self);
} }
self.deinit(); self.deinit();
} }
pub fn terminate(self: *Transfer) void { pub fn terminate(self: *Transfer) void {