mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-31 09:46:05 -04:00
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:
170
src/browser/tests/element/html/select-optgroup.html
Normal file
170
src/browser/tests/element/html/select-optgroup.html
Normal 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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user