forms: include optgroup children in a select's list of options

HTMLSelectElement collected its options from its direct children only, so an
<option> inside an <optgroup> was invisible to options, length, value,
selectedIndex, selectedOptions and to form submission. A grouped select could
not be read, set or submitted, while querySelectorAll("option") still found
every option.

Per HTML §the-select-element a select's list of options is its option element
descendants: its option children plus the option children of its optgroup
children.

- add a select_options collection mode, and move selected_options onto the
  same walk, matching an option whose parent is the select or an optgroup
  child of the select
- add one OptionIterator in Select.zig, used by effectiveOption, setValue,
  getSelectedIndex, setSelectedIndex, getLength and add
- effectiveOption skips options disabled through <optgroup disabled>
  (concept-option-disabled); those options only become reachable now that
  grouped options are part of the list
- select.add(option, index) inserts into the optgroup when the option at that
  index lives in one, per §dom-select-add

Closes #3057
This commit is contained in:
Navid EMAD
2026-07-25 04:31:41 +02:00
parent e8b58c5dd2
commit f239004bbb
4 changed files with 260 additions and 31 deletions

View File

@@ -0,0 +1,170 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!--
A select's list of options is its option children PLUS the option children of
its optgroup children:
https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element
-->
<form id="grouped_form">
<select id="grouped" name="s">
<option value="">placeholder</option>
<optgroup label="first">
<option value="a">A</option>
<option value="b">B</option>
</optgroup>
<optgroup label="second">
<option value="c">C</option>
</optgroup>
</select>
</form>
<script id="grouped_options_collection">
{
const select = $('#grouped')
// Options inside optgroups are part of the list, in tree order.
testing.expectEqual(4, select.options.length)
testing.expectEqual(4, select.length)
testing.expectEqual('', select.options[0].value)
testing.expectEqual('a', select.options[1].value)
testing.expectEqual('b', select.options[2].value)
testing.expectEqual('c', select.options[3].value)
testing.expectEqual(undefined, select.options[4])
}
</script>
<script id="grouped_value_setter">
{
const select = $('#grouped')
select.value = 'b'
testing.expectEqual('b', select.value)
testing.expectEqual(2, select.selectedIndex)
testing.expectEqual(1, select.selectedOptions.length)
testing.expectEqual('b', select.selectedOptions[0].value)
// Setting the value deselects every other option, grouped or not.
testing.expectFalse(select.options[0].selected)
testing.expectTrue(select.options[2].selected)
testing.expectFalse(select.options[3].selected)
}
</script>
<script id="grouped_option_selected_property">
{
const select = $('#grouped')
// Selecting through the option's own property, which the select has to
// see through the optgroup as well.
select.options[2].selected = false
select.options[3].selected = true
testing.expectEqual('c', select.value)
testing.expectEqual(3, select.selectedIndex)
}
</script>
<script id="grouped_selected_index_setter">
{
const select = $('#grouped')
select.selectedIndex = 1
testing.expectEqual('a', select.value)
testing.expectEqual('a', select.selectedOptions[0].value)
select.selectedIndex = -1
testing.expectEqual('', select.value)
testing.expectEqual(-1, select.selectedIndex)
testing.expectEqual(0, select.selectedOptions.length)
}
</script>
<script id="grouped_form_submission">
{
const select = $('#grouped')
select.value = 'c'
testing.expectEqual('c', new FormData($('#grouped_form')).get('s'))
}
</script>
<script id="grouped_iteration">
{
const values = []
for (const option of $('#grouped').options) {
values.push(option.value)
}
testing.expectEqual(['', 'a', 'b', 'c'], values)
}
</script>
<select id="grouped_selected_attribute">
<option value="x">X</option>
<optgroup label="g">
<option value="y" selected>Y</option>
</optgroup>
</select>
<script id="grouped_selected_attribute_wins">
{
const select = $('#grouped_selected_attribute')
// The explicitly selected option wins over the implicit first one, even
// from inside an optgroup.
testing.expectEqual('y', select.value)
testing.expectEqual(1, select.selectedIndex)
}
</script>
<select id="grouped_disabled">
<optgroup label="off" disabled>
<option value="skipped">skipped</option>
</optgroup>
<optgroup label="on">
<option value="taken">taken</option>
</optgroup>
</select>
<script id="grouped_disabled_optgroup_skipped">
{
// With nothing explicitly selected, the fallback is the first option that
// is not disabled — an option in a disabled optgroup is itself disabled
// (HTML "concept-option-disabled").
testing.expectEqual('taken', $('#grouped_disabled').value)
}
</script>
<select id="grouped_add">
<option value="one">one</option>
<optgroup label="g">
<option value="two">two</option>
</optgroup>
</select>
<script id="grouped_add_before_index">
{
const select = $('#grouped_add')
const option = document.createElement('option')
option.value = 'inserted'
// Index 1 is inside the optgroup, so that is where the option lands.
select.add(option, 1)
testing.expectEqual(3, select.options.length)
testing.expectEqual('inserted', select.options[1].value)
testing.expectEqual('two', select.options[2].value)
testing.expectEqual('OPTGROUP', option.parentElement.tagName)
}
</script>
<select id="nested_too_deep">
<option value="direct">direct</option>
<div>
<option value="buried">buried</option>
</div>
</select>
<script id="options_are_not_arbitrary_descendants">
{
// Only children and optgroup grandchildren count — not every descendant.
const select = $('#nested_too_deep')
testing.expectEqual(1, select.options.length)
testing.expectEqual('direct', select.options[0].value)
}
</script>

View File

@@ -36,6 +36,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -54,6 +55,7 @@ _data: union(Mode) {
child_elements: NodeLive(.child_elements),
child_tag: NodeLive(.child_tag),
cells: NodeLive(.cells),
select_options: NodeLive(.select_options),
selected_options: NodeLive(.selected_options),
links: NodeLive(.links),
anchors: NodeLive(.anchors),
@@ -111,6 +113,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator {
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
.child_tag => |*impl| .{ .child_tag = impl._tw.clone() },
.cells => |*impl| .{ .cells = impl._tw.clone() },
.select_options => |*impl| .{ .select_options = impl._tw.clone() },
.selected_options => |*impl| .{ .selected_options = impl._tw.clone() },
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
@@ -132,7 +135,8 @@ pub const Iterator = GenericIterator(struct {
child_elements: TreeWalker.Children,
child_tag: TreeWalker.Children,
cells: TreeWalker.Children,
selected_options: TreeWalker.Children,
select_options: TreeWalker.FullExcludeSelf,
selected_options: TreeWalker.FullExcludeSelf,
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
form: TreeWalker.FullExcludeSelf,
@@ -157,6 +161,7 @@ pub const Iterator = GenericIterator(struct {
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
.child_tag => |*impl| impl.nextTw(&self.tw.child_tag),
.cells => |*impl| impl.nextTw(&self.tw.cells),
.select_options => |*impl| impl.nextTw(&self.tw.select_options),
.selected_options => |*impl| impl.nextTw(&self.tw.selected_options),
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),

View File

@@ -40,6 +40,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -68,6 +69,7 @@ const Filters = union(Mode) {
child_elements,
child_tag: Element.Tag,
cells,
select_options,
selected_options,
links,
anchors,
@@ -100,7 +102,10 @@ pub fn NodeLive(comptime mode: Mode) type {
const Filter = Filters.TypeOf(mode);
const TW = switch (mode) {
.tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
.child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children,
.child_elements, .child_tag, .cells => TreeWalker.Children,
// A select's options can sit one level down, inside an <optgroup>, so
// these two walk the subtree and filter on the parent instead.
.select_options, .selected_options => TreeWalker.FullExcludeSelf,
};
return struct {
_tw: TW,
@@ -299,11 +304,23 @@ pub fn NodeLive(comptime mode: Mode) type {
const el = node.is(Element) orelse return false;
return el.is(Element.Html.TableCell) != null;
},
.selected_options => {
.select_options, .selected_options => {
// The select's list of options is its option children plus
// the option children of its optgroup children — NOT every
// option descendant.
// https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element
const el = node.is(Element) orelse return false;
const Option = Element.Html.Option;
const opt = el.is(Option) orelse return false;
return opt.getSelected();
const parent = node.parentNode() orelse return false;
if (parent != self._tw._root) {
const group = parent.is(Element) orelse return false;
if (group.getTag() != .optgroup) return false;
if (parent.parentNode() != self._tw._root) return false;
}
return if (comptime mode == .selected_options) opt.getSelected() else true;
},
.links => {
// Links are <a> elements with href attribute (TODO: also <area> when implemented)
@@ -390,6 +407,7 @@ pub fn NodeLive(comptime mode: Mode) type {
.child_elements => HTMLCollection{ ._data = .{ .child_elements = self } },
.child_tag => HTMLCollection{ ._data = .{ .child_tag = self } },
.cells => HTMLCollection{ ._data = .{ .cells = self } },
.select_options => HTMLCollection{ ._data = .{ .select_options = self } },
.selected_options => HTMLCollection{ ._data = .{ .selected_options = self } },
.links => HTMLCollection{ ._data = .{ .links = self } },
.anchors => HTMLCollection{ ._data = .{ .anchors = self } },

View File

@@ -48,6 +48,43 @@ pub fn asConstNode(self: *const Select) *const Node {
return self.asConstElement().asConstNode();
}
// Walks the select's list of options in tree order: its option children plus
// the option children of its optgroup children, per HTML
// §the-select-element. Options nested any deeper are not in the list.
const OptionIterator = struct {
// Cursor over the select's children.
_child: ?*Node,
// Cursor over the current optgroup's children, while inside one.
_in_group: ?*Node = null,
fn init(select: *const Select) OptionIterator {
return .{ ._child = select.asConstNode().firstChild() };
}
fn next(self: *OptionIterator) ?*Option {
while (true) {
if (self._in_group) |node| {
self._in_group = node.nextSibling();
if (node.is(Option)) |option| {
return option;
}
continue;
}
const node = self._child orelse return null;
self._child = node.nextSibling();
if (node.is(Option)) |option| {
return option;
}
const element = node.is(Element) orelse continue;
if (element.getTag() == .optgroup) {
self._in_group = node.firstChild();
}
}
}
};
// Resolves the option whose selectedness contributes to the select's value
// per HTML §form-elements§selectedness-setting-algorithm: an explicitly
// selected non-disabled option, falling back to the first non-disabled
@@ -56,10 +93,12 @@ pub fn asConstNode(self: *const Select) *const Node {
// and contributes no entry to a FormData set.
pub fn effectiveOption(self: *const Select) ?*Option {
var first_option: ?*Option = null;
var maybe_child = self.asConstNode().firstChild();
while (maybe_child) |child| : (maybe_child = child.nextSibling()) {
const option = child.is(Option) orelse continue;
if (option.getDisabled()) continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
// Element.isDisabled, not Option.getDisabled: an option is also
// disabled when its parent is an <optgroup disabled>
// (HTML "concept-option-disabled").
if (option.asElement().isDisabled()) continue;
if (option.getSelected()) return option;
if (first_option == null) first_option = option;
}
@@ -77,9 +116,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void {
// Find option with matching value and select it
// Note: This updates the current state (_selected), not the default state (attribute)
// Setting value always deselects all others, even for multiple selects
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
option._selected = std.mem.eql(u8, option.getValue(frame), value);
}
}
@@ -87,9 +125,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void {
pub fn getSelectedIndex(self: *Select) i32 {
var index: i32 = 0;
var has_options = false;
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
has_options = true;
if (option.getSelected()) {
return index;
@@ -112,9 +149,8 @@ pub fn setSelectedIndex(self: *Select, index: i32) !void {
// Note: This updates the current state (_selected), not the default state (attribute)
const is_multiple = self.getMultiple();
var current_index: i32 = 0;
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
var it = OptionIterator.init(self);
while (it.next()) |option| {
if (current_index == index) {
option._selected = true;
} else if (!is_multiple) {
@@ -200,8 +236,9 @@ pub fn setRequired(self: *Select, required: bool, frame: *Frame) !void {
}
pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollection {
// For options, we use the child_tag mode to filter only <option> elements
const node_live = collections.NodeLive(.child_tag).init(self.asNode(), .option, frame);
// select_options mode is the select's list of options: option children
// plus the option children of optgroup children.
const node_live = collections.NodeLive(.select_options).init(self.asNode(), {}, frame);
const html_collection = try node_live.runtimeGenericWrap(frame);
// Create and return HTMLOptionsCollection
@@ -213,11 +250,9 @@ pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollect
pub fn getLength(self: *Select) u32 {
var i: u32 = 0;
var it = self.asNode().childrenIterator();
while (it.next()) |child| {
if (child.is(Option) != null) {
i += 1;
}
var it = OptionIterator.init(self);
while (it.next()) |_| {
i += 1;
}
return i;
}
@@ -235,13 +270,10 @@ pub fn add(self: *Select, element: *Option, before_: ?AddBeforeOption, frame: *F
switch (before) {
.index => |idx| {
var i: u32 = 0;
var it = self_node.childrenIterator();
while (it.next()) |child| {
if (child.is(Option) == null) {
continue;
}
var it = OptionIterator.init(self);
while (it.next()) |option| {
if (i == idx) {
before_node = child;
before_node = option.asNode();
break;
}
i += 1;
@@ -250,7 +282,10 @@ pub fn add(self: *Select, element: *Option, before_: ?AddBeforeOption, frame: *F
.option => |before_option| before_node = before_option.asNode(),
}
}
_ = try self_node.insertBefore(element.asElement().asNode(), before_node, frame);
// Per HTML §dom-select-add, the insertion parent is `before`'s parent —
// which is the optgroup when `before` sits inside one.
const parent = if (before_node) |node| node.parentNode() orelse self_node else self_node;
_ = try parent.insertBefore(element.asElement().asNode(), before_node, frame);
}
pub fn getSelectedOptions(self: *Select, frame: *Frame) !collections.NodeLive(.selected_options) {
@@ -387,5 +422,6 @@ const std = @import("std");
const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Select" {
try testing.htmlRunner("element/html/select.html", .{});
try testing.htmlRunner("element/html/select-optgroup.html", .{});
try testing.htmlRunner("element/html/select-validity.html", .{});
}