fix(forms): implement option nearest select algorithm

This commit is contained in:
ldm0
2026-09-07 23:10:21 +08:00
parent 449c44bb7f
commit ef2ad11cbd
14 changed files with 259 additions and 201 deletions
@@ -3139,8 +3139,6 @@ html/semantics/forms/textfieldselection/selection.html
html/semantics/forms/the-input-element/show-picker-cross-origin-iframe.html
html/semantics/forms/the-select-element/customizable-select/base-appearance-inheritance.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-optgroup.html
html/semantics/forms/the-select-element/customizable-select/option-form-ancestor-select.html
html/semantics/forms/the-select-element/customizable-select/option-list.html
html/semantics/forms/the-select-element/customizable-select/picker-icon-animation.html
html/semantics/forms/the-select-element/customizable-select/select-base-appearance-computed-style.html
html/semantics/forms/the-select-element/customizable-select/select-value-selectedOption.html
@@ -5847,6 +5847,8 @@ html/semantics/forms/the-select-element/common-HTMLOptionsCollection-namedItem.h
html/semantics/forms/the-select-element/common-HTMLOptionsCollection.html
html/semantics/forms/the-select-element/customizable-select/option-color-inheritance.html
html/semantics/forms/the-select-element/customizable-select/option-disabled-invalid-nesting.html
html/semantics/forms/the-select-element/customizable-select/option-form-ancestor-select.html
html/semantics/forms/the-select-element/customizable-select/option-list.html
html/semantics/forms/the-select-element/customizable-select/select-options-id.html
html/semantics/forms/the-select-element/customizable-select/select-parsing.html
html/semantics/forms/the-select-element/inserted-or-removed.html
+2
View File
@@ -1,6 +1,7 @@
mod button_type;
mod input_type;
mod numeric;
mod option;
mod text;
mod validity;
@@ -33,6 +34,7 @@ pub use numeric::{
parse_html_floating_point_prefix, parse_input_numeric_value, progress_element_values,
step_input_value,
};
pub use option::{OptionNearestSelectStep, OptionNearestSelectTraversal};
pub use text::{
normalize_custom_validation_message, normalize_form_submission_newlines,
parse_non_negative_integer_prefix, parse_non_negative_length_attribute,
+37
View File
@@ -0,0 +1,37 @@
const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionNearestSelectStep {
Continue,
Select,
Blocked,
}
/// State for the HTML "option element nearest ancestor select" algorithm.
///
/// Tree owners perform the actual ancestor walk and feed element names into
/// this state machine. Keeping the HTML association rule here lets native DOM
/// trees and detached bridge trees share the same barriers.
#[derive(Debug, Default)]
pub struct OptionNearestSelectTraversal {
saw_optgroup: bool,
}
impl OptionNearestSelectTraversal {
pub fn visit_ancestor(&mut self, namespace: &str, local_name: &str) -> OptionNearestSelectStep {
if namespace != HTML_NAMESPACE {
return OptionNearestSelectStep::Continue;
}
match local_name {
"datalist" | "hr" | "option" => OptionNearestSelectStep::Blocked,
"optgroup" if self.saw_optgroup => OptionNearestSelectStep::Blocked,
"optgroup" => {
self.saw_optgroup = true;
OptionNearestSelectStep::Continue
}
"select" => OptionNearestSelectStep::Select,
_ => OptionNearestSelectStep::Continue,
}
}
}
+5 -47
View File
@@ -162,6 +162,10 @@ impl DomHost {
None
}
pub fn option_nearest_ancestor_select(&self, handle: DomHandle) -> Option<DomHandle> {
self.dom.option_nearest_ancestor_select(handle)
}
pub fn radio_group_members(&self, handle: DomHandle) -> Vec<DomHandle> {
let Some(element) = self.node(handle).and_then(Node::as_element) else {
return Vec::new();
@@ -188,32 +192,8 @@ impl DomHost {
})
}
pub fn owner_select_for_option(&self, handle: DomHandle) -> Option<DomHandle> {
if !self.is_html_element_named(handle, "option") {
return None;
}
let mut current = self.parent_node(handle);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
if element.is_html_select() {
return Some(parent);
}
current = self.parent_node(parent);
}
None
}
pub fn select_option_elements(&self, select_handle: DomHandle) -> Vec<DomHandle> {
if !self.is_html_element_named(select_handle, "select") {
return Vec::new();
}
self.collect_matching_elements(select_handle, false, |handle| {
self.is_html_element_named(handle, "option")
&& self.option_belongs_to_select(handle, select_handle)
})
self.dom.select_option_elements(select_handle)
}
pub fn select_selected_option_elements(&self, select_handle: DomHandle) -> Vec<DomHandle> {
@@ -251,28 +231,6 @@ impl DomHost {
.collect()
}
fn option_belongs_to_select(&self, option: DomHandle, select_handle: DomHandle) -> bool {
let mut current = self.parent_node(option);
let mut seen_optgroup = false;
while let Some(parent) = current {
if parent == select_handle {
return true;
}
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
match element.local_name() {
"option" | "hr" | "select" => return false,
"optgroup" if seen_optgroup => return false,
"optgroup" => seen_optgroup = true,
_ => {}
}
current = self.parent_node(parent);
}
false
}
fn option_is_disabled(&self, handle: DomHandle) -> bool {
let mut current = Some(handle);
while let Some(candidate) = current {
@@ -254,7 +254,7 @@ impl DomHost {
if !name.eq_ignore_ascii_case("selected") || !self.is_html_element_named(handle, "option") {
return;
}
let Some(select) = self.owner_select_for_option(handle) else {
let Some(select) = self.option_nearest_ancestor_select(handle) else {
return;
};
if self
+6 -2
View File
@@ -520,7 +520,11 @@ impl DomHost {
.node(option)
.and_then(Node::as_element)
.is_some_and(Element::selected);
(option, self.owner_select_for_option(option), selected)
(
option,
self.option_nearest_ancestor_select(option),
selected,
)
})
.collect()
}
@@ -530,7 +534,7 @@ impl DomHost {
snapshots: &[(DomHandle, Option<DomHandle>, bool)],
) {
for &(option, previous_select, was_selected) in snapshots {
let Some(select) = self.owner_select_for_option(option) else {
let Some(select) = self.option_nearest_ancestor_select(option) else {
continue;
};
if previous_select == Some(select)
+59
View File
@@ -1529,6 +1529,65 @@ mod tests {
);
}
#[test]
fn select_option_list_uses_option_nearest_ancestor_select() {
let mut host = DomHost::from_dom(NativeDom::new_html(test_url()));
let document = host.document_node_id();
let parent_select = host.create_element("select");
let child_select = host.create_element("select");
assert!(host.append_child(document, parent_select));
assert!(host.append_child(parent_select, child_select));
let normal_option = host.create_element("option");
let nested_option = host.create_element("option");
assert!(host.append_child(child_select, normal_option));
assert!(host.append_child(normal_option, nested_option));
let div = host.create_element("div");
let div_option = host.create_element("option");
assert!(host.append_child(child_select, div));
assert!(host.append_child(div, div_option));
let hr = host.create_element("hr");
let hr_option = host.create_element("option");
assert!(host.append_child(child_select, hr));
assert!(host.append_child(hr, hr_option));
let datalist = host.create_element("datalist");
let datalist_option = host.create_element("option");
assert!(host.append_child(child_select, datalist));
assert!(host.append_child(datalist, datalist_option));
let optgroup = host.create_element("optgroup");
let optgroup_option = host.create_element("option");
let nested_optgroup = host.create_element("optgroup");
let nested_optgroup_option = host.create_element("option");
assert!(host.append_child(child_select, optgroup));
assert!(host.append_child(optgroup, optgroup_option));
assert!(host.append_child(optgroup, nested_optgroup));
assert!(host.append_child(nested_optgroup, nested_optgroup_option));
assert!(host.select_option_elements(parent_select).is_empty());
assert_eq!(
host.select_option_elements(child_select),
vec![normal_option, div_option, optgroup_option]
);
for option in [normal_option, div_option, optgroup_option] {
assert_eq!(
host.option_nearest_ancestor_select(option),
Some(child_select)
);
}
for option in [
nested_option,
hr_option,
datalist_option,
nested_optgroup_option,
] {
assert_eq!(host.option_nearest_ancestor_select(option), None);
}
}
#[test]
fn selected_option_insertion_deselects_single_select_peers_without_dirtying_them() {
let mut host = DomHost::from_dom(NativeDom::new_html(test_url()));
+31 -43
View File
@@ -1,7 +1,9 @@
use super::NativeDom;
use super::element::Element;
use super::node::{NativeNodeId, Node};
use crate::forms::parse_non_negative_integer_prefix;
use crate::forms::{
OptionNearestSelectStep, OptionNearestSelectTraversal, parse_non_negative_integer_prefix,
};
impl NativeDom {
pub fn is_html_element_named(&self, node_id: NativeNodeId, local_name: &str) -> bool {
@@ -40,7 +42,7 @@ impl NativeDom {
if !option.is_html_option() {
return false;
}
if let Some(select_id) = self.owner_select_for_option(option_id) {
if let Some(select_id) = self.option_nearest_ancestor_select(option_id) {
return self
.select_selected_option_elements(select_id)
.contains(&option_id);
@@ -58,10 +60,36 @@ impl NativeDom {
}
self.elements_by_tag_name(select_id, "option", false)
.into_iter()
.filter(|option_id| self.option_belongs_to_select(*option_id, select_id))
.filter(|option_id| self.option_nearest_ancestor_select(*option_id) == Some(select_id))
.collect()
}
pub fn option_nearest_ancestor_select(&self, option_id: NativeNodeId) -> Option<NativeNodeId> {
if !self
.node(option_id)
.and_then(Node::as_element)
.is_some_and(Element::is_html_option)
{
return None;
}
let mut traversal = OptionNearestSelectTraversal::default();
let mut current = self.parent_node(option_id);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
match traversal.visit_ancestor(element.namespace(), element.local_name()) {
OptionNearestSelectStep::Continue => {}
OptionNearestSelectStep::Select => return Some(parent),
OptionNearestSelectStep::Blocked => return None,
}
current = self.parent_node(parent);
}
None
}
pub fn select_selected_option_elements(&self, select_id: NativeNodeId) -> Vec<NativeNodeId> {
let options = self.select_option_elements(select_id);
let Some(select) = self.node(select_id).and_then(Node::as_element) else {
@@ -181,46 +209,6 @@ impl NativeDom {
out
}
fn option_belongs_to_select(&self, option_id: NativeNodeId, select_id: NativeNodeId) -> bool {
let mut current = self.parent_node(option_id);
let mut seen_optgroup = false;
while let Some(parent) = current {
if parent == select_id {
return true;
}
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
match element.local_name() {
"option" | "hr" | "select" => return false,
"optgroup" if seen_optgroup => return false,
"optgroup" => seen_optgroup = true,
_ => {}
}
current = self.parent_node(parent);
}
false
}
fn owner_select_for_option(&self, option_id: NativeNodeId) -> Option<NativeNodeId> {
let mut current = self.parent_node(option_id);
while let Some(parent) = current {
let Some(element) = self.node(parent).and_then(Node::as_element) else {
current = self.parent_node(parent);
continue;
};
if element.is_html_select() {
return Some(parent);
}
if matches!(element.local_name(), "option" | "hr" | "select") {
return None;
}
current = self.parent_node(parent);
}
None
}
fn option_is_disabled(&self, option_id: NativeNodeId) -> bool {
let mut current = Some(option_id);
while let Some(candidate) = current {
@@ -28,7 +28,7 @@ impl DocumentRuntime {
if !element.is_html_option() {
return false;
}
if let Some(select) = self.owner_select_for_option(handle) {
if let Some(select) = self.dom_host.option_nearest_ancestor_select(handle) {
return self
.dom_host
.select_selected_option_elements(select)
@@ -37,17 +37,6 @@ impl DocumentRuntime {
element.selected()
}
fn owner_select_for_option(&self, handle: DomHandle) -> Option<DomHandle> {
let mut current = self.dom_host.parent_node(handle);
while let Some(parent) = current {
if self.dom_host.is_html_element_named(parent, "select") {
return Some(parent);
}
current = self.dom_host.parent_node(parent);
}
None
}
fn preserve_selectedness_for_options_removed_from_select(
&mut self,
scope: &mut v8::PinScope<'_, '_>,
@@ -55,7 +44,12 @@ impl DocumentRuntime {
selectedness: &[(DomHandle, bool)],
) {
for &(option, was_selected) in selectedness {
if was_selected && self.owner_select_for_option(option).is_none() {
if was_selected
&& self
.dom_host
.option_nearest_ancestor_select(option)
.is_none()
{
let _ = self.set_selected_state_with_dirty(scope, host_ptr, option, true, false);
}
}
@@ -1464,7 +1464,7 @@ fn perform_option_click_default_action(
handle: DomHandle,
) -> bool {
let runtime = unsafe { &*runtime_ptr };
let Some(select_handle) = owner_select_for_option(runtime, handle) else {
let Some(select_handle) = runtime.dom_host().option_nearest_ancestor_select(handle) else {
return false;
};
if is_disabled_form_control(runtime, handle) || is_disabled_form_control(runtime, select_handle)
@@ -1499,25 +1499,6 @@ fn perform_option_click_default_action(
true
}
fn owner_select_for_option(runtime: &JsContextHost, handle: DomHandle) -> Option<DomHandle> {
if !is_html_option_element(runtime, handle) {
return None;
}
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
if runtime
.dom_host()
.node(parent)
.and_then(Node::as_element)
.is_some_and(Element::is_html_select)
{
return Some(parent);
}
current = runtime.dom_host().parent_node(parent);
}
None
}
fn is_html_option_element(runtime: &JsContextHost, handle: DomHandle) -> bool {
runtime
.dom_host()
@@ -25,20 +25,11 @@ pub(super) fn effective_option_selected(runtime: &JsContextHost, handle: DomHand
return false;
}
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
let Some(parent_element) = runtime.dom_host().node(parent).and_then(Node::as_element)
else {
current = runtime.dom_host().parent_node(parent);
continue;
};
if parent_element.is_html_select() {
return runtime
.dom_host()
.select_selected_option_elements(parent)
.contains(&handle);
}
current = runtime.dom_host().parent_node(parent);
if let Some(select) = runtime.dom_host().option_nearest_ancestor_select(handle) {
return runtime
.dom_host()
.select_selected_option_elements(select)
.contains(&handle);
}
element.selected()
}
@@ -2,10 +2,11 @@ use super::helpers::{
effective_option_selected, element_option_value, select_is_multiple, select_option_handles,
};
use super::*;
use crate::dom::forms::{OptionNearestSelectStep, OptionNearestSelectTraversal};
use crate::native_bridge::{
document::{
detached_element_local_name, detached_form_owner_object, detached_parent_node_object,
set_detached_text_replacement_value,
detached_element_local_name, detached_element_namespace_uri, detached_form_owner_object,
detached_parent_node_object, set_detached_text_replacement_value,
},
element::{html_element_getter_receiver, html_element_setter_receiver},
};
@@ -86,11 +87,19 @@ fn detached_option_form_owner_object<'s>(
option: v8::Local<'s, v8::Object>,
) -> Option<Option<v8::Local<'s, v8::Object>>> {
let mut current = detached_parent_node_object(scope, option)?;
let mut traversal = OptionNearestSelectTraversal::default();
loop {
if detached_element_local_name(scope, current)
.is_some_and(|name| name.eq_ignore_ascii_case("select"))
{
return Some(detached_form_owner_object(scope, current));
if let (Some(namespace), Some(local_name)) = (
detached_element_namespace_uri(scope, current),
detached_element_local_name(scope, current),
) {
match traversal.visit_ancestor(&namespace, &local_name) {
OptionNearestSelectStep::Continue => {}
OptionNearestSelectStep::Select => {
return Some(detached_form_owner_object(scope, current));
}
OptionNearestSelectStep::Blocked => return Some(None),
}
}
let Some(parent) = detached_parent_node_object(scope, current) else {
return Some(None);
@@ -203,19 +212,7 @@ pub(in crate::native_bridge) fn option_selected_setter_function<'s>(
};
let selected = args.get(0).boolean_value(scope);
let runtime = unsafe { &mut *runtime_ptr };
let mut owner_select = runtime.dom_host().parent_node(handle);
while let Some(parent) = owner_select {
if runtime
.dom_host()
.node(parent)
.and_then(Node::as_element)
.is_some_and(Element::is_html_select)
{
owner_select = Some(parent);
break;
}
owner_select = runtime.dom_host().parent_node(parent);
}
let owner_select = runtime.dom_host().option_nearest_ancestor_select(handle);
if let Some(select_handle) = owner_select
&& selected
&& !select_is_multiple(runtime, select_handle)
@@ -252,21 +249,11 @@ pub(in crate::native_bridge) fn option_form_getter_function<'s>(
return;
}
let runtime = unsafe { &*runtime_ptr };
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
if runtime
.dom_host()
.node(parent)
.and_then(Node::as_element)
.is_some_and(Element::is_html_select)
{
let form = form_associated_form_owner(runtime, parent);
set_wrapped_node_or_null(scope, &mut rv, runtime_ptr, form);
return;
}
current = runtime.dom_host().parent_node(parent);
}
rv.set_null();
let form = runtime
.dom_host()
.option_nearest_ancestor_select(handle)
.and_then(|select| form_associated_form_owner(runtime, select));
set_wrapped_node_or_null(scope, &mut rv, runtime_ptr, form);
}
pub(in crate::native_bridge) fn option_index_getter_function<'s>(
@@ -279,33 +266,16 @@ pub(in crate::native_bridge) fn option_index_getter_function<'s>(
return;
};
let runtime = unsafe { &*runtime_ptr };
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
if runtime
.dom_host()
.node(parent)
.and_then(Node::as_element)
.is_some_and(Element::is_html_select)
{
let index = select_option_handles(runtime, parent)
let index = runtime
.dom_host()
.option_nearest_ancestor_select(handle)
.and_then(|select| {
select_option_handles(runtime, select)
.into_iter()
.position(|option| option == handle)
.unwrap_or(0) as i32;
rv.set_int32(index);
return;
}
if runtime
.dom_host()
.node(parent)
.and_then(Node::as_element)
.is_some_and(|element| element.local_name() == "datalist")
{
rv.set_int32(0);
return;
}
current = runtime.dom_host().parent_node(parent);
}
rv.set_int32(0);
})
.unwrap_or(0) as i32;
rv.set_int32(index);
}
pub(in crate::native_bridge) fn option_label_getter_function<'s>(
@@ -563,6 +563,80 @@ fn detached_select_and_option_track_selection_state() {
);
}
#[test]
fn option_nearest_ancestor_select_controls_list_form_index_and_selectedness() {
let mut vm = new_storage_test_vm("https://option-nearest-select.test/");
let result = vm
.eval(
r#"
(() => {
const root = document.documentElement || document.appendChild(document.createElement('html'));
const body = document.body || root.appendChild(document.createElement('body'));
const form = document.createElement('form');
const parentSelect = document.createElement('select');
const childSelect = document.createElement('select');
form.append(parentSelect);
parentSelect.append(childSelect);
body.append(form);
const normalOption = document.createElement('option');
childSelect.append(normalOption);
const nestedOption = document.createElement('option');
normalOption.append(nestedOption);
const div = document.createElement('div');
childSelect.append(div);
const divOption = document.createElement('option');
div.append(divOption);
const hr = document.createElement('hr');
childSelect.append(hr);
const hrOption = document.createElement('option');
hr.append(hrOption);
const datalist = document.createElement('datalist');
childSelect.append(datalist);
const datalistOption = document.createElement('option');
datalist.append(datalistOption);
const optgroup = document.createElement('optgroup');
childSelect.append(optgroup);
const optgroupOption = document.createElement('option');
optgroup.append(optgroupOption);
const nestedOptgroup = document.createElement('optgroup');
optgroup.append(nestedOptgroup);
const nestedOptgroupOption = document.createElement('option');
nestedOptgroup.append(nestedOptgroupOption);
normalOption.selected = true;
datalistOption.selected = true;
return JSON.stringify({
parentLength: parentSelect.length,
childLength: childSelect.length,
options: Array.from(childSelect.options, option =>
option === normalOption ? 'normal' :
option === divOption ? 'div' :
option === optgroupOption ? 'optgroup' : 'unexpected'),
validForms: [normalOption, divOption, optgroupOption].map(option => option.form === form),
invalidForms: [nestedOption, hrOption, datalistOption, nestedOptgroupOption]
.map(option => option.form === null),
indices: [normalOption, divOption, optgroupOption, nestedOption, hrOption,
datalistOption, nestedOptgroupOption].map(option => option.index),
normalStillSelected: normalOption.selected
});
})()
"#,
)
.expect("option nearest ancestor select behavior should evaluate");
assert_eq!(
result,
r#"{"parentLength":0,"childLength":3,"options":["normal","div","optgroup"],"validForms":[true,true,true],"invalidForms":[true,true,true,true],"indices":[0,1,2,0,0,0,0],"normalStillSelected":true}"#
);
}
#[test]
fn child_document_select_exposes_detached_mutation_surface() {
let mut vm = new_storage_test_vm("https://child-select-options-surface.test/");