webapi.Element: centralize disabled state logic

This commit is contained in:
Adrià Arrufat
2026-03-24 13:13:53 +09:00
parent 260768463b
commit 567cd97312
5 changed files with 33 additions and 111 deletions

View File

@@ -179,15 +179,13 @@ fn collectFormFields(
page: *Page, page: *Page,
) ![]FormField { ) ![]FormField {
var fields: std.ArrayList(FormField) = .empty; var fields: std.ArrayList(FormField) = .empty;
const form_node = form.asNode();
var elements = try form.getElements(page); var elements = try form.getElements(page);
var it = try elements.iterator(); var it = try elements.iterator();
while (it.next()) |el| { while (it.next()) |el| {
const node = el.asNode(); const node = el.asNode();
const is_disabled = el.getAttributeSafe(comptime .wrap("disabled")) != null or const is_disabled = el.isDisabled();
isDisabledByFieldset(el, form_node);
if (el.is(Element.Html.Input)) |input| { if (el.is(Element.Html.Input)) |input| {
if (input._input_type == .hidden) continue; if (input._input_type == .hidden) continue;
@@ -267,38 +265,6 @@ fn collectSelectOptions(
return options.items; return options.items;
} }
/// Returns true if `element` is disabled by an ancestor <fieldset disabled>,
/// stopping at the form boundary.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(element: *Element, form_node: *Node) bool {
const element_node = element.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
if (node == form_node) {
return false;
}
current = node._parent;
const el = node.is(Element) orelse continue;
if (el.getTag() == .fieldset and el.getAttributeSafe(comptime .wrap("disabled")) != null) {
var child = el.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) {
return false;
}
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
fn testForms(html: []const u8) ![]FormInfo { fn testForms(html: []const u8) ![]FormInfo {

View File

@@ -162,7 +162,7 @@ pub fn collectInteractiveElements(
.name = try getAccessibleName(el, arena), .name = try getAccessibleName(el, arena),
.interactivity_type = itype, .interactivity_type = itype,
.listener_types = listener_types, .listener_types = listener_types,
.disabled = isDisabled(el), .disabled = el.isDisabled(),
.tab_index = html_el.getTabIndex(), .tab_index = html_el.getTabIndex(),
.id = el.getAttributeSafe(comptime .wrap("id")), .id = el.getAttributeSafe(comptime .wrap("id")),
.class = el.getAttributeSafe(comptime .wrap("class")), .class = el.getAttributeSafe(comptime .wrap("class")),
@@ -412,36 +412,6 @@ fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
// strip out trailing space // strip out trailing space
return arr.items[0 .. arr.items.len - 1]; return arr.items[0 .. arr.items.len - 1];
} }
fn isDisabled(el: *Element) bool {
if (el.getAttributeSafe(comptime .wrap("disabled")) != null) return true;
return isDisabledByFieldset(el);
}
/// Check if an element is disabled by an ancestor <fieldset disabled>.
/// Per spec, elements inside the first <legend> child of a disabled fieldset
/// are NOT disabled by that fieldset.
fn isDisabledByFieldset(el: *Element) bool {
const element_node = el.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if element is inside the first <legend> child of this fieldset
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
fn getInputType(el: *Element) ?[]const u8 { fn getInputType(el: *Element) ?[]const u8 {
if (el.is(Element.Html.Input)) |input| { if (el.is(Element.Html.Input)) |input| {

View File

@@ -573,6 +573,32 @@ pub fn hasAttributeSafe(self: *const Element, name: String) bool {
return attributes.hasSafe(name); return attributes.hasSafe(name);
} }
pub fn isDisabled(self: *Element) bool {
if (self.getAttributeSafe(comptime .wrap("disabled")) != null) {
return true;
}
const element_node = self.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
current = node._parent;
const ancestor = node.is(Element) orelse continue;
if (ancestor.getTag() == .fieldset and ancestor.getAttributeSafe(comptime .wrap("disabled")) != null) {
var child = ancestor.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
if (c.asNode().contains(element_node)) return false;
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
pub fn hasAttributes(self: *const Element) bool { pub fn hasAttributes(self: *const Element) bool {
const attributes = self._attributes orelse return false; const attributes = self._attributes orelse return false;
return attributes.isEmpty() == false; return attributes.isEmpty() == false;

View File

@@ -125,15 +125,10 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
var list: KeyValueList = .empty; var list: KeyValueList = .empty;
const form = form_ orelse return list; const form = form_ orelse return list;
const form_node = form.asNode();
var elements = try form.getElements(page); var elements = try form.getElements(page);
var it = try elements.iterator(); var it = try elements.iterator();
while (it.next()) |element| { while (it.next()) |element| {
if (element.getAttributeSafe(comptime .wrap("disabled")) != null) { if (element.isDisabled()) {
continue;
}
if (isDisabledByFieldset(element, form_node)) {
continue; continue;
} }
@@ -202,41 +197,6 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Pa
return list; return list;
} }
// Returns true if `element` is disabled by an ancestor <fieldset disabled>,
// stopping the upward walk when the form node is reached.
// Per spec, elements inside the first <legend> child of a disabled fieldset
// are NOT disabled by that fieldset.
fn isDisabledByFieldset(element: *Element, form_node: *Node) bool {
const element_node = element.asNode();
var current: ?*Node = element_node._parent;
while (current) |node| {
// Stop at the form boundary (common case optimisation)
if (node == form_node) {
return false;
}
current = node._parent;
const el = node.is(Element) orelse continue;
if (el.getTag() == .fieldset and el.getAttributeSafe(comptime .wrap("disabled")) != null) {
// Check if `element` is inside the first <legend> child of this fieldset
var child = el.firstElementChild();
while (child) |c| {
if (c.getTag() == .legend) {
// Found the first legend; exempt if element is a descendant
if (c.asNode().contains(element_node)) {
return false;
}
break;
}
child = c.nextElementSibling();
}
return true;
}
}
return false;
}
pub const JsApi = struct { pub const JsApi = struct {
pub const bridge = js.Bridge(FormData); pub const bridge = js.Bridge(FormData);

View File

@@ -295,7 +295,7 @@ pub const Writer = struct {
}, },
.input => { .input => {
const input = el.as(DOMNode.Element.Html.Input); const input = el.as(DOMNode.Element.Html.Input);
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); const is_disabled = el.isDisabled();
switch (input._input_type) { switch (input._input_type) {
.text, .email, .tel, .url, .search, .password, .number => { .text, .email, .tel, .url, .search, .password, .number => {
@@ -332,7 +332,7 @@ pub const Writer = struct {
} }
}, },
.textarea => { .textarea => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) { if (!is_disabled) {
@@ -347,7 +347,7 @@ pub const Writer = struct {
try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w); try self.writeAXProperty(.{ .name = .required, .value = .{ .boolean = el.hasAttributeSafe(comptime .wrap("required")) } }, w);
}, },
.select => { .select => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) { if (!is_disabled) {
@@ -391,7 +391,7 @@ pub const Writer = struct {
} }
}, },
.button => { .button => {
const is_disabled = el.hasAttributeSafe(comptime .wrap("disabled")); const is_disabled = el.isDisabled();
try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w); try self.writeAXProperty(.{ .name = .invalid, .value = .{ .token = "false" } }, w);
if (!is_disabled) { if (!is_disabled) {
try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w); try self.writeAXProperty(.{ .name = .focusable, .value = .{ .booleanOrUndefined = true } }, w);