mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 04:34:44 +00:00
Merge pull request #1537 from lightpanda-io/input_click
Default behavior for input click (radio / checkbox).
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
283
src/browser/tests/element/html/input_click.html
Normal file
283
src/browser/tests/element/html/input_click.html
Normal 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>
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -1255,7 +1255,6 @@ pub const Transfer = struct {
|
||||
client.endTransfer(self);
|
||||
}
|
||||
self.deinit();
|
||||
|
||||
}
|
||||
|
||||
pub fn terminate(self: *Transfer) void {
|
||||
|
||||
Reference in New Issue
Block a user